Skip to content

Commit 273bb85

Browse files
jonathanlabclaude
andauthored
feat: configurable workspaces (#172)
Co-authored-by: Claude <[email protected]>
1 parent 159a7a9 commit 273bb85

File tree

89 files changed

+4113
-1968
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

89 files changed

+4113
-1968
lines changed

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,64 @@ array-monorepo/
6565
├── pnpm-workspace.yaml # Workspace configuration
6666
└── package.json # Root package.json
6767
```
68+
69+
## Workspace Configuration (array.json)
70+
71+
Array supports per-repository configuration through an `array.json` file. This lets you define scripts that run automatically when workspaces are created or destroyed.
72+
73+
### File Locations
74+
75+
Array searches for configuration in this order:
76+
77+
1. `.array/{workspace-name}/array.json` - Workspace-specific config
78+
2. `array.json` - Repository root config
79+
80+
### Schema
81+
82+
```json
83+
{
84+
"scripts": {
85+
"init": "npm install",
86+
"start": ["npm run server", "npm run client"],
87+
"destroy": "docker-compose down"
88+
}
89+
}
90+
```
91+
92+
| Script | When it runs | Behavior |
93+
|--------|--------------|----------|
94+
| `init` | Workspace creation | Runs first, fails fast (stops on error) |
95+
| `start` | After init completes | Continues even if scripts fail |
96+
| `destroy` | Workspace deletion | Runs silently before cleanup |
97+
98+
Each script can be a single command string or an array of commands. Commands run sequentially in dedicated terminal sessions.
99+
100+
### Examples
101+
102+
Install dependencies on workspace creation:
103+
```json
104+
{
105+
"scripts": {
106+
"init": "pnpm install"
107+
}
108+
}
109+
```
110+
111+
Start development servers:
112+
```json
113+
{
114+
"scripts": {
115+
"init": ["pnpm install", "pnpm run build"],
116+
"start": ["pnpm run dev", "pnpm run storybook"]
117+
}
118+
}
119+
```
120+
121+
Clean up Docker containers:
122+
```json
123+
{
124+
"scripts": {
125+
"destroy": "docker-compose down -v"
126+
}
127+
}
128+
```

apps/array/src/main/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ import {
3838
shutdownPostHog,
3939
trackAppEvent,
4040
} from "./services/posthog-analytics.js";
41+
import { registerSettingsIpc } from "./services/settings.js";
4142
import { registerShellIpc } from "./services/shell.js";
4243
import { registerAutoUpdater } from "./services/updates.js";
44+
import { registerWorkspaceIpc } from "./services/workspace/index.js";
4345
import { registerWorktreeIpc } from "./services/worktree.js";
4446

4547
const __filename = fileURLToPath(import.meta.url);
@@ -244,7 +246,9 @@ registerGitIpc(() => mainWindow);
244246
registerAgentIpc(taskControllers, () => mainWindow);
245247
registerFsIpc();
246248
registerFileWatcherIpc(() => mainWindow);
247-
registerFoldersIpc();
249+
registerFoldersIpc(() => mainWindow);
248250
registerWorktreeIpc();
249251
registerShellIpc();
250252
registerExternalAppsIpc();
253+
registerWorkspaceIpc(() => mainWindow);
254+
registerSettingsIpc();
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { type IpcMainInvokeEvent, ipcMain } from "electron";
2+
import { logger } from "./logger";
3+
4+
type IpcHandler<T extends unknown[], R> = (
5+
event: IpcMainInvokeEvent,
6+
...args: T
7+
) => Promise<R> | R;
8+
9+
interface HandleOptions {
10+
scope?: string;
11+
rethrow?: boolean;
12+
fallback?: unknown;
13+
}
14+
15+
export function createIpcHandler(scope: string) {
16+
const log = logger.scope(scope);
17+
18+
return function handle<T extends unknown[], R>(
19+
channel: string,
20+
handler: IpcHandler<T, R>,
21+
options: HandleOptions = {},
22+
): void {
23+
const { rethrow = true, fallback } = options;
24+
25+
ipcMain.handle(channel, async (event: IpcMainInvokeEvent, ...args: T) => {
26+
try {
27+
return await handler(event, ...args);
28+
} catch (error) {
29+
log.error(`Failed to handle ${channel}:`, error);
30+
if (rethrow) {
31+
throw error;
32+
}
33+
return fallback as R;
34+
}
35+
});
36+
};
37+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import * as fs from "node:fs";
2+
import * as os from "node:os";
3+
import type { WebContents } from "electron";
4+
import * as pty from "node-pty";
5+
import { logger } from "./logger";
6+
7+
const log = logger.scope("shell");
8+
9+
export interface ShellSession {
10+
pty: pty.IPty;
11+
webContents: WebContents;
12+
exitPromise: Promise<{ exitCode: number }>;
13+
command?: string;
14+
}
15+
16+
function getDefaultShell(): string {
17+
const platform = os.platform();
18+
if (platform === "win32") {
19+
return process.env.COMSPEC || "cmd.exe";
20+
}
21+
return process.env.SHELL || "/bin/bash";
22+
}
23+
24+
function buildShellEnv(): Record<string, string> {
25+
const env = { ...process.env } as Record<string, string>;
26+
27+
if (os.platform() === "darwin" && !process.env.LC_ALL) {
28+
const locale = process.env.LC_CTYPE || "en_US.UTF-8";
29+
env.LANG = locale;
30+
env.LC_ALL = locale;
31+
env.LC_MESSAGES = locale;
32+
env.LC_NUMERIC = locale;
33+
env.LC_COLLATE = locale;
34+
env.LC_MONETARY = locale;
35+
}
36+
37+
env.TERM_PROGRAM = "Array";
38+
env.COLORTERM = "truecolor";
39+
env.FORCE_COLOR = "3";
40+
41+
return env;
42+
}
43+
44+
export interface CreateSessionOptions {
45+
sessionId: string;
46+
webContents: WebContents;
47+
cwd?: string;
48+
initialCommand?: string;
49+
}
50+
51+
class ShellManagerImpl {
52+
private sessions = new Map<string, ShellSession>();
53+
54+
createSession(options: CreateSessionOptions): ShellSession {
55+
const { sessionId, webContents, cwd, initialCommand } = options;
56+
57+
const existing = this.sessions.get(sessionId);
58+
if (existing) {
59+
return existing;
60+
}
61+
62+
const shell = getDefaultShell();
63+
const homeDir = os.homedir();
64+
let workingDir = cwd || homeDir;
65+
66+
if (!fs.existsSync(workingDir)) {
67+
log.warn(
68+
`Shell session ${sessionId}: cwd "${workingDir}" does not exist, falling back to home`,
69+
);
70+
workingDir = homeDir;
71+
}
72+
73+
log.info(
74+
`Creating shell session ${sessionId}: shell=${shell}, cwd=${workingDir}`,
75+
);
76+
77+
const env = buildShellEnv();
78+
const ptyProcess = pty.spawn(shell, ["-l"], {
79+
name: "xterm-256color",
80+
cols: 80,
81+
rows: 24,
82+
cwd: workingDir,
83+
env,
84+
encoding: null,
85+
});
86+
87+
let resolveExit: (result: { exitCode: number }) => void;
88+
const exitPromise = new Promise<{ exitCode: number }>((resolve) => {
89+
resolveExit = resolve;
90+
});
91+
92+
ptyProcess.onData((data: string) => {
93+
webContents.send(`shell:data:${sessionId}`, data);
94+
});
95+
96+
ptyProcess.onExit(({ exitCode }) => {
97+
log.info(`Shell session ${sessionId} exited with code ${exitCode}`);
98+
webContents.send(`shell:exit:${sessionId}`, { exitCode });
99+
this.sessions.delete(sessionId);
100+
resolveExit({ exitCode });
101+
});
102+
103+
if (initialCommand) {
104+
setTimeout(() => {
105+
ptyProcess.write(`${initialCommand}\n`);
106+
}, 100);
107+
}
108+
109+
const session: ShellSession = {
110+
pty: ptyProcess,
111+
webContents,
112+
exitPromise,
113+
command: initialCommand,
114+
};
115+
116+
this.sessions.set(sessionId, session);
117+
return session;
118+
}
119+
120+
getSession(sessionId: string): ShellSession | undefined {
121+
return this.sessions.get(sessionId);
122+
}
123+
124+
hasSession(sessionId: string): boolean {
125+
return this.sessions.has(sessionId);
126+
}
127+
128+
write(sessionId: string, data: string): void {
129+
const session = this.sessions.get(sessionId);
130+
if (!session) {
131+
throw new Error(`Shell session ${sessionId} not found`);
132+
}
133+
session.pty.write(data);
134+
}
135+
136+
resize(sessionId: string, cols: number, rows: number): void {
137+
const session = this.sessions.get(sessionId);
138+
if (!session) {
139+
throw new Error(`Shell session ${sessionId} not found`);
140+
}
141+
session.pty.resize(cols, rows);
142+
}
143+
144+
destroy(sessionId: string): void {
145+
const session = this.sessions.get(sessionId);
146+
if (!session) {
147+
return;
148+
}
149+
session.pty.kill();
150+
this.sessions.delete(sessionId);
151+
}
152+
153+
getProcess(sessionId: string): string | null {
154+
const session = this.sessions.get(sessionId);
155+
return session?.pty.process ?? null;
156+
}
157+
158+
getSessionsByPrefix(prefix: string): string[] {
159+
const result: string[] = [];
160+
for (const sessionId of this.sessions.keys()) {
161+
if (sessionId.startsWith(prefix)) {
162+
result.push(sessionId);
163+
}
164+
}
165+
return result;
166+
}
167+
168+
destroyByPrefix(prefix: string): void {
169+
for (const sessionId of this.sessions.keys()) {
170+
if (sessionId.startsWith(prefix)) {
171+
this.destroy(sessionId);
172+
}
173+
}
174+
}
175+
}
176+
177+
export const shellManager = new ShellManagerImpl();

0 commit comments

Comments
 (0)