Skip to content

Commit 8eb718b

Browse files
authored
feat: workspace environment variables (#191)
1 parent 36b8d38 commit 8eb718b

File tree

14 files changed

+235
-36
lines changed

14 files changed

+235
-36
lines changed

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,42 @@ Clean up Docker containers:
126126
}
127127
}
128128
```
129+
130+
## Workspace Environment Variables
131+
132+
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.
133+
134+
| Variable | Description | Example |
135+
|----------|-------------|---------|
136+
| `ARRAY_WORKSPACE_NAME` | Worktree name, or folder name in root mode | `my-feature-branch` |
137+
| `ARRAY_WORKSPACE_PATH` | Absolute path to the workspace | `/Users/dev/.array/worktrees/repo/my-feature` |
138+
| `ARRAY_ROOT_PATH` | Absolute path to the repository root | `/Users/dev/repos/my-project` |
139+
| `ARRAY_DEFAULT_BRANCH` | Default branch detected from git | `main` |
140+
| `ARRAY_WORKSPACE_BRANCH` | Initial branch when workspace was created | `array/my-feature` |
141+
| `ARRAY_WORKSPACE_PORTS` | Comma-separated list of allocated ports | `50000,50001,...,50019` |
142+
| `ARRAY_WORKSPACE_PORTS_RANGE` | Number of ports allocated | `20` |
143+
| `ARRAY_WORKSPACE_PORTS_START` | First port in the range | `50000` |
144+
| `ARRAY_WORKSPACE_PORTS_END` | Last port in the range | `50019` |
145+
146+
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.
147+
148+
### Port Allocation
149+
150+
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.
151+
152+
### Usage Examples
153+
154+
Use ports in your start scripts:
155+
```json
156+
{
157+
"scripts": {
158+
"start": "npm run dev -- --port $ARRAY_WORKSPACE_PORTS_START"
159+
}
160+
}
161+
```
162+
163+
Reference the workspace path:
164+
```bash
165+
echo "Working in: $ARRAY_WORKSPACE_NAME"
166+
echo "Root repo: $ARRAY_ROOT_PATH"
167+
```

apps/array/src/main/lib/shellManager.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ function getDefaultShell(): string {
2121
return process.env.SHELL || "/bin/bash";
2222
}
2323

24-
function buildShellEnv(): Record<string, string> {
24+
function buildShellEnv(
25+
additionalEnv?: Record<string, string>,
26+
): Record<string, string> {
2527
const env = { ...process.env } as Record<string, string>;
2628

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

43+
if (additionalEnv) {
44+
Object.assign(env, additionalEnv);
45+
}
46+
4147
return env;
4248
}
4349

@@ -46,13 +52,15 @@ export interface CreateSessionOptions {
4652
webContents: WebContents;
4753
cwd?: string;
4854
initialCommand?: string;
55+
additionalEnv?: Record<string, string>;
4956
}
5057

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

5461
createSession(options: CreateSessionOptions): ShellSession {
55-
const { sessionId, webContents, cwd, initialCommand } = options;
62+
const { sessionId, webContents, cwd, initialCommand, additionalEnv } =
63+
options;
5664

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

77-
const env = buildShellEnv();
85+
const env = buildShellEnv(additionalEnv);
7886
const ptyProcess = pty.spawn(shell, ["-l"], {
7987
name: "xterm-256color",
8088
cols: 80,

apps/array/src/main/preload.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,9 +302,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
302302
createVoidIpcListener("updates:ready", listener),
303303
installUpdate: (): Promise<{ installed: boolean }> =>
304304
ipcRenderer.invoke("updates:install"),
305-
// Shell API
306-
shellCreate: (sessionId: string, cwd?: string): Promise<void> =>
307-
ipcRenderer.invoke("shell:create", sessionId, cwd),
305+
shellCreate: (
306+
sessionId: string,
307+
cwd?: string,
308+
taskId?: string,
309+
): Promise<void> =>
310+
ipcRenderer.invoke("shell:create", sessionId, cwd, taskId),
308311
shellWrite: (sessionId: string, data: string): Promise<void> =>
309312
ipcRenderer.invoke("shell:write", sessionId, data),
310313
shellResize: (sessionId: string, cols: number, rows: number): Promise<void> =>

apps/array/src/main/services/git.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const getRemoteUrl = async (
7777
}
7878
};
7979

80-
const getCurrentBranch = async (
80+
export const getCurrentBranch = async (
8181
directoryPath: string,
8282
): Promise<string | undefined> => {
8383
try {
@@ -90,7 +90,9 @@ const getCurrentBranch = async (
9090
}
9191
};
9292

93-
const getDefaultBranch = async (directoryPath: string): Promise<string> => {
93+
export const getDefaultBranch = async (
94+
directoryPath: string,
95+
): Promise<string> => {
9496
try {
9597
// Try to get the default branch from origin
9698
const { stdout } = await execAsync(

apps/array/src/main/services/shell.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,38 @@
11
import { createIpcHandler } from "../lib/ipcHandler";
22
import { shellManager } from "../lib/shellManager";
3+
import { foldersStore } from "./store";
4+
import { buildWorkspaceEnv } from "./workspace/workspaceEnv";
35

46
const handle = createIpcHandler("shell");
57

68
export function registerShellIpc(): void {
7-
handle("shell:create", (event, sessionId: string, cwd?: string) => {
8-
shellManager.createSession({
9-
sessionId,
10-
webContents: event.sender,
11-
cwd,
12-
});
13-
});
9+
handle(
10+
"shell:create",
11+
async (event, sessionId: string, cwd?: string, taskId?: string) => {
12+
let additionalEnv: Record<string, string> | undefined;
13+
14+
if (taskId) {
15+
const associations = foldersStore.get("taskAssociations", []);
16+
const association = associations.find((a) => a.taskId === taskId);
17+
if (association && association.mode !== "cloud") {
18+
additionalEnv = await buildWorkspaceEnv({
19+
taskId,
20+
folderPath: association.folderPath,
21+
worktreePath: association.worktree?.worktreePath ?? null,
22+
worktreeName: association.worktree?.worktreeName ?? null,
23+
mode: association.mode,
24+
});
25+
}
26+
}
27+
28+
shellManager.createSession({
29+
sessionId,
30+
webContents: event.sender,
31+
cwd,
32+
additionalEnv,
33+
});
34+
},
35+
);
1436

1537
handle("shell:write", (_event, sessionId: string, data: string) => {
1638
shellManager.write(sessionId, data);

apps/array/src/main/services/workspace/scriptRunner.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export class ScriptRunner {
3333
scripts: string | string[],
3434
scriptType: "init" | "start",
3535
cwd: string,
36-
options: { failFast?: boolean } = {},
36+
options: { failFast?: boolean; workspaceEnv?: Record<string, string> } = {},
3737
): Promise<ScriptExecutionResult> {
3838
const commands = Array.isArray(scripts) ? scripts : [scripts];
3939
const terminalSessionIds: string[] = [];
@@ -67,6 +67,7 @@ export class ScriptRunner {
6767
webContents: mainWindow.webContents,
6868
cwd,
6969
initialCommand: command,
70+
additionalEnv: options.workspaceEnv,
7071
});
7172

7273
terminalSessionIds.push(sessionId);
@@ -112,14 +113,19 @@ export class ScriptRunner {
112113
async executeScriptsSilent(
113114
scripts: string | string[],
114115
cwd: string,
116+
workspaceEnv?: Record<string, string>,
115117
): Promise<{ success: boolean; errors: string[] }> {
116118
const commands = Array.isArray(scripts) ? scripts : [scripts];
117119
const errors: string[] = [];
118120

121+
const execEnv = workspaceEnv
122+
? { ...process.env, ...workspaceEnv }
123+
: undefined;
124+
119125
for (const command of commands) {
120126
log.info(`Running destroy script silently: ${command}`);
121127
try {
122-
await execAsync(command, { cwd, timeout: 60000 });
128+
await execAsync(command, { cwd, timeout: 60000, env: execEnv });
123129
log.info(`Destroy script completed: ${command}`);
124130
} catch (error) {
125131
const errorMessage =
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import path from "node:path";
2+
import type { WorkspaceMode } from "@shared/types";
3+
import { getCurrentBranch, getDefaultBranch } from "../git";
4+
5+
export interface WorkspaceEnvContext {
6+
taskId: string;
7+
folderPath: string;
8+
worktreePath: string | null;
9+
worktreeName: string | null;
10+
mode: WorkspaceMode;
11+
}
12+
13+
const PORT_BASE = 50000;
14+
const PORTS_PER_WORKSPACE = 20;
15+
const MAX_WORKSPACES = 1000;
16+
17+
function hashTaskId(taskId: string): number {
18+
let hash = 0;
19+
for (let i = 0; i < taskId.length; i++) {
20+
const char = taskId.charCodeAt(i);
21+
hash = (hash << 5) - hash + char;
22+
hash = hash & hash;
23+
}
24+
return Math.abs(hash);
25+
}
26+
27+
function allocateWorkspacePorts(taskId: string): {
28+
start: number;
29+
end: number;
30+
ports: number[];
31+
} {
32+
const workspaceIndex = hashTaskId(taskId) % MAX_WORKSPACES;
33+
const start = PORT_BASE + workspaceIndex * PORTS_PER_WORKSPACE;
34+
const end = start + PORTS_PER_WORKSPACE - 1;
35+
36+
const ports: number[] = [];
37+
for (let port = start; port <= end; port++) {
38+
ports.push(port);
39+
}
40+
41+
return { start, end, ports };
42+
}
43+
44+
export async function buildWorkspaceEnv(
45+
context: WorkspaceEnvContext,
46+
): Promise<Record<string, string>> {
47+
if (context.mode === "cloud") {
48+
return {};
49+
}
50+
51+
const workspaceName =
52+
context.worktreeName ?? path.basename(context.folderPath);
53+
const workspacePath = context.worktreePath ?? context.folderPath;
54+
const rootPath = context.folderPath;
55+
56+
const defaultBranch = await getDefaultBranch(rootPath);
57+
58+
const workspaceBranch = (await getCurrentBranch(workspacePath)) ?? "";
59+
60+
const portAllocation = allocateWorkspacePorts(context.taskId);
61+
62+
return {
63+
ARRAY_WORKSPACE_NAME: workspaceName,
64+
ARRAY_WORKSPACE_PATH: workspacePath,
65+
ARRAY_ROOT_PATH: rootPath,
66+
ARRAY_DEFAULT_BRANCH: defaultBranch,
67+
ARRAY_WORKSPACE_BRANCH: workspaceBranch,
68+
ARRAY_WORKSPACE_PORTS: portAllocation.ports.join(","),
69+
ARRAY_WORKSPACE_PORTS_RANGE: String(PORTS_PER_WORKSPACE),
70+
ARRAY_WORKSPACE_PORTS_START: String(portAllocation.start),
71+
ARRAY_WORKSPACE_PORTS_END: String(portAllocation.end),
72+
};
73+
}

0 commit comments

Comments
 (0)