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
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,42 @@ Clean up Docker containers:
}
}
```

## Workspace Environment Variables

Array automatically sets environment variables in all workspace terminals and scripts. These are available in `init`, `start`, and `destroy` scripts, as well as any terminal sessions opened within a workspace.

| Variable | Description | Example |
|----------|-------------|---------|
| `ARRAY_WORKSPACE_NAME` | Worktree name, or folder name in root mode | `my-feature-branch` |
| `ARRAY_WORKSPACE_PATH` | Absolute path to the workspace | `/Users/dev/.array/worktrees/repo/my-feature` |
| `ARRAY_ROOT_PATH` | Absolute path to the repository root | `/Users/dev/repos/my-project` |
| `ARRAY_DEFAULT_BRANCH` | Default branch detected from git | `main` |
| `ARRAY_WORKSPACE_BRANCH` | Initial branch when workspace was created | `array/my-feature` |
| `ARRAY_WORKSPACE_PORTS` | Comma-separated list of allocated ports | `50000,50001,...,50019` |
| `ARRAY_WORKSPACE_PORTS_RANGE` | Number of ports allocated | `20` |
| `ARRAY_WORKSPACE_PORTS_START` | First port in the range | `50000` |
| `ARRAY_WORKSPACE_PORTS_END` | Last port in the range | `50019` |

Note: `ARRAY_WORKSPACE_BRANCH` reflects the branch at workspace creation time. If you or the agent checks out a different branch, this variable will still show the original branch name.

### Port Allocation

Each workspace is assigned a unique range of 20 ports starting from port 50000. The allocation is deterministic based on the task ID, so the same workspace always receives the same ports across restarts.

### Usage Examples

Use ports in your start scripts:
```json
{
"scripts": {
"start": "npm run dev -- --port $ARRAY_WORKSPACE_PORTS_START"
}
}
```

Reference the workspace path:
```bash
echo "Working in: $ARRAY_WORKSPACE_NAME"
echo "Root repo: $ARRAY_ROOT_PATH"
```
14 changes: 11 additions & 3 deletions apps/array/src/main/lib/shellManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ function getDefaultShell(): string {
return process.env.SHELL || "/bin/bash";
}

function buildShellEnv(): Record<string, string> {
function buildShellEnv(
additionalEnv?: Record<string, string>,
): Record<string, string> {
const env = { ...process.env } as Record<string, string>;

if (os.platform() === "darwin" && !process.env.LC_ALL) {
Expand All @@ -38,6 +40,10 @@ function buildShellEnv(): Record<string, string> {
env.COLORTERM = "truecolor";
env.FORCE_COLOR = "3";

if (additionalEnv) {
Object.assign(env, additionalEnv);
}

return env;
}

Expand All @@ -46,13 +52,15 @@ export interface CreateSessionOptions {
webContents: WebContents;
cwd?: string;
initialCommand?: string;
additionalEnv?: Record<string, string>;
}

class ShellManagerImpl {
private sessions = new Map<string, ShellSession>();

createSession(options: CreateSessionOptions): ShellSession {
const { sessionId, webContents, cwd, initialCommand } = options;
const { sessionId, webContents, cwd, initialCommand, additionalEnv } =
options;

const existing = this.sessions.get(sessionId);
if (existing) {
Expand All @@ -74,7 +82,7 @@ class ShellManagerImpl {
`Creating shell session ${sessionId}: shell=${shell}, cwd=${workingDir}`,
);

const env = buildShellEnv();
const env = buildShellEnv(additionalEnv);
const ptyProcess = pty.spawn(shell, ["-l"], {
name: "xterm-256color",
cols: 80,
Expand Down
9 changes: 6 additions & 3 deletions apps/array/src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
createVoidIpcListener("updates:ready", listener),
installUpdate: (): Promise<{ installed: boolean }> =>
ipcRenderer.invoke("updates:install"),
// Shell API
shellCreate: (sessionId: string, cwd?: string): Promise<void> =>
ipcRenderer.invoke("shell:create", sessionId, cwd),
shellCreate: (
sessionId: string,
cwd?: string,
taskId?: string,
): Promise<void> =>
ipcRenderer.invoke("shell:create", sessionId, cwd, taskId),
shellWrite: (sessionId: string, data: string): Promise<void> =>
ipcRenderer.invoke("shell:write", sessionId, data),
shellResize: (sessionId: string, cols: number, rows: number): Promise<void> =>
Expand Down
6 changes: 4 additions & 2 deletions apps/array/src/main/services/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const getRemoteUrl = async (
}
};

const getCurrentBranch = async (
export const getCurrentBranch = async (
directoryPath: string,
): Promise<string | undefined> => {
try {
Expand All @@ -90,7 +90,9 @@ const getCurrentBranch = async (
}
};

const getDefaultBranch = async (directoryPath: string): Promise<string> => {
export const getDefaultBranch = async (
directoryPath: string,
): Promise<string> => {
try {
// Try to get the default branch from origin
const { stdout } = await execAsync(
Expand Down
36 changes: 29 additions & 7 deletions apps/array/src/main/services/shell.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
import { createIpcHandler } from "../lib/ipcHandler";
import { shellManager } from "../lib/shellManager";
import { foldersStore } from "./store";
import { buildWorkspaceEnv } from "./workspace/workspaceEnv";

const handle = createIpcHandler("shell");

export function registerShellIpc(): void {
handle("shell:create", (event, sessionId: string, cwd?: string) => {
shellManager.createSession({
sessionId,
webContents: event.sender,
cwd,
});
});
handle(
"shell:create",
async (event, sessionId: string, cwd?: string, taskId?: string) => {
let additionalEnv: Record<string, string> | undefined;

if (taskId) {
const associations = foldersStore.get("taskAssociations", []);
const association = associations.find((a) => a.taskId === taskId);
if (association && association.mode !== "cloud") {
additionalEnv = await buildWorkspaceEnv({
taskId,
folderPath: association.folderPath,
worktreePath: association.worktree?.worktreePath ?? null,
worktreeName: association.worktree?.worktreeName ?? null,
mode: association.mode,
});
}
}

shellManager.createSession({
sessionId,
webContents: event.sender,
cwd,
additionalEnv,
});
},
);

handle("shell:write", (_event, sessionId: string, data: string) => {
shellManager.write(sessionId, data);
Expand Down
10 changes: 8 additions & 2 deletions apps/array/src/main/services/workspace/scriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class ScriptRunner {
scripts: string | string[],
scriptType: "init" | "start",
cwd: string,
options: { failFast?: boolean } = {},
options: { failFast?: boolean; workspaceEnv?: Record<string, string> } = {},
): Promise<ScriptExecutionResult> {
const commands = Array.isArray(scripts) ? scripts : [scripts];
const terminalSessionIds: string[] = [];
Expand Down Expand Up @@ -67,6 +67,7 @@ export class ScriptRunner {
webContents: mainWindow.webContents,
cwd,
initialCommand: command,
additionalEnv: options.workspaceEnv,
});

terminalSessionIds.push(sessionId);
Expand Down Expand Up @@ -112,14 +113,19 @@ export class ScriptRunner {
async executeScriptsSilent(
scripts: string | string[],
cwd: string,
workspaceEnv?: Record<string, string>,
): Promise<{ success: boolean; errors: string[] }> {
const commands = Array.isArray(scripts) ? scripts : [scripts];
const errors: string[] = [];

const execEnv = workspaceEnv
? { ...process.env, ...workspaceEnv }
: undefined;

for (const command of commands) {
log.info(`Running destroy script silently: ${command}`);
try {
await execAsync(command, { cwd, timeout: 60000 });
await execAsync(command, { cwd, timeout: 60000, env: execEnv });
log.info(`Destroy script completed: ${command}`);
} catch (error) {
const errorMessage =
Expand Down
73 changes: 73 additions & 0 deletions apps/array/src/main/services/workspace/workspaceEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import path from "node:path";
import type { WorkspaceMode } from "@shared/types";
import { getCurrentBranch, getDefaultBranch } from "../git";

export interface WorkspaceEnvContext {
taskId: string;
folderPath: string;
worktreePath: string | null;
worktreeName: string | null;
mode: WorkspaceMode;
}

const PORT_BASE = 50000;
const PORTS_PER_WORKSPACE = 20;
const MAX_WORKSPACES = 1000;

function hashTaskId(taskId: string): number {
let hash = 0;
for (let i = 0; i < taskId.length; i++) {
const char = taskId.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash);
}

function allocateWorkspacePorts(taskId: string): {
start: number;
end: number;
ports: number[];
} {
const workspaceIndex = hashTaskId(taskId) % MAX_WORKSPACES;
const start = PORT_BASE + workspaceIndex * PORTS_PER_WORKSPACE;
const end = start + PORTS_PER_WORKSPACE - 1;

const ports: number[] = [];
for (let port = start; port <= end; port++) {
ports.push(port);
}

return { start, end, ports };
}

export async function buildWorkspaceEnv(
context: WorkspaceEnvContext,
): Promise<Record<string, string>> {
if (context.mode === "cloud") {
return {};
}

const workspaceName =
context.worktreeName ?? path.basename(context.folderPath);
const workspacePath = context.worktreePath ?? context.folderPath;
const rootPath = context.folderPath;

const defaultBranch = await getDefaultBranch(rootPath);

const workspaceBranch = (await getCurrentBranch(workspacePath)) ?? "";

const portAllocation = allocateWorkspacePorts(context.taskId);

return {
ARRAY_WORKSPACE_NAME: workspaceName,
ARRAY_WORKSPACE_PATH: workspacePath,
ARRAY_ROOT_PATH: rootPath,
ARRAY_DEFAULT_BRANCH: defaultBranch,
ARRAY_WORKSPACE_BRANCH: workspaceBranch,
ARRAY_WORKSPACE_PORTS: portAllocation.ports.join(","),
ARRAY_WORKSPACE_PORTS_RANGE: String(PORTS_PER_WORKSPACE),
ARRAY_WORKSPACE_PORTS_START: String(portAllocation.start),
ARRAY_WORKSPACE_PORTS_END: String(portAllocation.end),
};
}
Loading