dev-orchestrator-connector 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # Dev Orchestrator Connector
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The connector runs on your computer and uses your existing local Codex sign-in.
4
+ It downloads only repositories authorized for the paired executor. The cloud
5
+ service coordinates tasks but never hosts your source checkout.
6
+
7
+ Requirements: Node.js 22.18 or newer and the official Codex CLI signed in.
8
+
9
+ Install the connector in your home directory:
10
+
11
+ ```bash
12
+ cd "$HOME" && npm i dev-orchestrator-connector
13
+ ```
14
+
15
+ Pair this machine using the one-time code shown in **Executors**:
16
+
17
+ ```bash
18
+ "$HOME/node_modules/.bin/dev-orchestrator-connector" pair \
19
+ --server https://YOUR-ORCHESTRATOR-HOST \
20
+ --code YOUR_ONE_USE_CODE \
21
+ --config "$HOME/.local/share/dev-orchestrator/connector.json"
22
+ ```
23
+
24
+ An owner must bind the executor to a project in the web app. Then start it with:
25
+
26
+ ```bash
27
+ "$HOME/node_modules/.bin/dev-orchestrator-connector" run \
28
+ --config "$HOME/.local/share/dev-orchestrator/connector.json"
29
+ ```
30
+
31
+ Keep that terminal open while the executor should process tasks. Press `Ctrl+C`
32
+ to stop it safely. The connector stores its credential and runtime data under
33
+ `~/.local/share/dev-orchestrator` with owner-only permissions.
34
+
35
+ This package contains only the local connector. It does not include the cloud
36
+ control plane or provider credentials.
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const cli = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
7
+ const child = spawn(process.execPath, [cli, ...process.argv.slice(2)], {
8
+ stdio: "inherit",
9
+ });
10
+
11
+ child.on("error", (error) => {
12
+ console.error(`Could not start the connector: ${error.message}`);
13
+ process.exitCode = 1;
14
+ });
15
+
16
+ child.on("exit", (code, signal) => {
17
+ process.exitCode = code ?? (signal ? 1 : 0);
18
+ });
@@ -0,0 +1,22 @@
1
+ const validationCommand = /\b(test|tests|spec|lint|format|typecheck|type-check|check|build|compile)\b/i;
2
+ export function activityForNotification(message) {
3
+ if (!message || typeof message !== "object" || !message.params)
4
+ return undefined;
5
+ if (message.method === "turn/started")
6
+ return "agent_started";
7
+ const item = message.params.item;
8
+ if (message.method === "item/started") {
9
+ if (item?.type === "fileChange")
10
+ return "editing";
11
+ if (item?.type === "commandExecution") {
12
+ return validationCommand.test(String(item.command ?? "")) ? "validating" : "working";
13
+ }
14
+ }
15
+ if (message.method === "item/completed" &&
16
+ item?.type === "commandExecution" &&
17
+ validationCommand.test(String(item.command ?? "")) &&
18
+ item.exitCode === 0) {
19
+ return "checks_passed";
20
+ }
21
+ return undefined;
22
+ }
package/dist/api.js ADDED
@@ -0,0 +1,24 @@
1
+ import { serverURL } from "./config.js";
2
+ export class APIError extends Error {
3
+ status;
4
+ code;
5
+ constructor(status, code) { super(`Control plane request failed (${status}: ${code}).`); this.status = status; this.code = code; }
6
+ }
7
+ export class API {
8
+ origin;
9
+ token;
10
+ constructor(origin, token) { this.origin = serverURL(origin); this.token = token; }
11
+ async post(path, data) {
12
+ if (!/^\/[a-z/-]+$/.test(path))
13
+ throw new Error("Invalid control-plane route.");
14
+ const response = await fetch(this.origin + "/api/v1" + path, {
15
+ method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000),
16
+ headers: { "content-type": "application/json", ...(this.token ? { authorization: `Bearer ${this.token}` } : {}) },
17
+ body: JSON.stringify(data)
18
+ });
19
+ const body = await response.json();
20
+ if (!response.ok)
21
+ throw new APIError(response.status, typeof body.error === "string" && /^[a-z_]+$/.test(body.error) ? body.error : "request_failed");
22
+ return path === "/enroll" ? body : body.data;
23
+ }
24
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,90 @@
1
+ import { hostname } from "node:os";
2
+ import { dirname, join } from "node:path";
3
+ import { mkdir, open, unlink } from "node:fs/promises";
4
+ import { pathToFileURL } from "node:url";
5
+ import { API } from "./api.js";
6
+ import { loadConfig, saveConfig, serverURL } from "./config.js";
7
+ import { CodexSession } from "./codex.js";
8
+ import { runLoop } from "./worker.js";
9
+ export async function main(args) {
10
+ const [action, ...rest] = args;
11
+ if (!action || action === "help" || action === "--help") {
12
+ console.log("Dev Orchestrator connector\n\npair --server URL --code CODE --config /absolute/config.json\nstatus --config FILE\nrun --config FILE\n\nProjects and repository access are managed in the web app. This connector uses your official local Codex login and never modifies global provider/Git configuration.");
13
+ return;
14
+ }
15
+ const opts = {};
16
+ for (let i = 0; i < rest.length; i += 2) {
17
+ if (!/^--[a-z-]+$/.test(rest[i]) || !rest[i + 1])
18
+ throw new Error("Options require --name value pairs.");
19
+ opts[rest[i].slice(2)] = rest[i + 1];
20
+ }
21
+ if (!opts.config || !opts.config.startsWith("/"))
22
+ throw new Error("Provide an absolute --config path.");
23
+ const file = opts.config;
24
+ if (action === "pair") {
25
+ // Do not consume a one-use code if a pairing already exists.
26
+ const reservation = await open(file + ".pairing", "wx", 0o600).catch(() => { throw new Error("Configuration directory must exist and no pairing may already be in progress."); });
27
+ try {
28
+ try {
29
+ await loadConfig(file);
30
+ throw new Error("This configuration is already paired.");
31
+ }
32
+ catch (error) {
33
+ if (error.code !== "ENOENT")
34
+ throw error;
35
+ }
36
+ const server = serverURL(opts.server);
37
+ const result = await new API(server).post("/enroll", { code: opts.code, name: opts.name ?? hostname() });
38
+ if (typeof result.token !== "string")
39
+ throw new Error("Invalid enrollment response.");
40
+ await saveConfig(file, { version: 2, server, token: result.token }, true);
41
+ console.log("Paired. Ask the owner to bind a project and install its repository in the web app.");
42
+ }
43
+ finally {
44
+ await reservation.close();
45
+ await unlink(file + ".pairing");
46
+ }
47
+ return;
48
+ }
49
+ const config = await loadConfig(file);
50
+ if (action === "allow") {
51
+ console.log("The local allow command is no longer needed. Bind this executor to the project and configure its GitHub App repository access in the web app.");
52
+ return;
53
+ }
54
+ if (action === "status") {
55
+ const session = CodexSession.start(config.codexBinary ?? "codex", dirname(file));
56
+ try {
57
+ await session.initialize();
58
+ console.log(JSON.stringify({ codex_authenticated: await session.authenticated(), server: config.server }));
59
+ }
60
+ finally {
61
+ session.stop();
62
+ }
63
+ return;
64
+ }
65
+ if (action !== "run")
66
+ throw new Error("Unknown command. Use help.");
67
+ const root = join(dirname(file), "runtime");
68
+ await mkdir(root, { recursive: true, mode: 0o700 });
69
+ const lock = join(root, "connector.lock");
70
+ const fd = await open(lock, "wx", 0o600).catch(() => { throw new Error("Connector lock exists. Confirm no connector is running before removing that exact lock file."); });
71
+ await fd.writeFile(String(process.pid));
72
+ const controller = new AbortController();
73
+ const stop = () => controller.abort();
74
+ process.once("SIGINT", stop);
75
+ process.once("SIGTERM", stop);
76
+ try {
77
+ console.log("Connector running. Press Ctrl+C to stop; interrupted work is preserved.");
78
+ await runLoop(config, root, controller.signal);
79
+ }
80
+ finally {
81
+ controller.abort();
82
+ process.off("SIGINT", stop);
83
+ process.off("SIGTERM", stop);
84
+ await fd.close();
85
+ await unlink(lock);
86
+ }
87
+ }
88
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
89
+ main(process.argv.slice(2)).catch(error => { console.error(error instanceof Error ? error.message : "Connector operation failed."); process.exitCode = 1; });
90
+ }
package/dist/codex.js ADDED
@@ -0,0 +1,202 @@
1
+ import { spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { createHash } from "node:crypto";
4
+ import { stopProcess } from "./process.js";
5
+ import { activityForNotification } from "./activity.js";
6
+ export class RepeatedFailureError extends Error {
7
+ fingerprint;
8
+ constructor(fingerprint) { super("Codex repeated the same failed command three times."); this.fingerprint = fingerprint; }
9
+ }
10
+ export class CodexBlockedError extends Error {
11
+ }
12
+ export class CodexSession {
13
+ child;
14
+ events = new EventEmitter();
15
+ pending = new Map();
16
+ id = 0;
17
+ buffer = "";
18
+ failed = null;
19
+ commandFailures = new Map();
20
+ constructor(child) {
21
+ this.child = child;
22
+ child.stderr.resume();
23
+ child.stdin.on("error", () => this.fail(new Error("Codex transport closed.")));
24
+ child.stdout.on("data", chunk => this.consume(chunk.toString()));
25
+ child.on("error", () => this.fail(new Error("Codex could not start. Check the locally installed CLI.")));
26
+ child.on("close", () => this.fail(new Error("Codex process stopped before completion.")));
27
+ }
28
+ static start(binary, workspace) {
29
+ // Keep provider-owned local login, but never forward API/Git tokens into the agent.
30
+ const allowed = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL", "CODEX_HOME", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"];
31
+ const env = Object.fromEntries(allowed.filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]]));
32
+ const args = ["app-server", "--listen", "stdio://",
33
+ "-c", 'permissions.dev-orchestrator.extends=":workspace"',
34
+ "-c", 'permissions.dev-orchestrator.filesystem={":workspace_roots"={"."="write",".git"="write",".agents"="write",".codex"="read",".aws"="read"}}',
35
+ "-c", "permissions.dev-orchestrator.network.enabled=false"];
36
+ return new CodexSession(spawn(binary, args, { cwd: workspace, env, detached: true, stdio: ["pipe", "pipe", "pipe"] }));
37
+ }
38
+ async initialize() {
39
+ await this.request("initialize", { clientInfo: { name: "dev_orchestrator", title: "Dev Orchestrator", version: "0.1.0" }, capabilities: { experimentalApi: true } });
40
+ this.send({ method: "initialized", params: {} });
41
+ }
42
+ async authenticated() {
43
+ const result = await this.request("account/read", { refreshToken: false });
44
+ return result.account?.type === "chatgpt";
45
+ }
46
+ async run(workspace, prompt, model, signal, onProgress) {
47
+ const thread = await this.request("thread/start", { cwd: workspace, approvalPolicy: "never", permissions: "dev-orchestrator", ...(model ? { model } : {}) });
48
+ const threadId = thread.thread?.id;
49
+ if (!threadId)
50
+ throw new Error("Codex returned no task thread.");
51
+ let onNotification;
52
+ let onFailure;
53
+ let abort;
54
+ let outcome;
55
+ let lastStage;
56
+ let progressQueue = Promise.resolve();
57
+ let progressFailure;
58
+ const completed = new Promise((resolve, reject) => {
59
+ onNotification = (message) => {
60
+ if (message.params?.threadId === threadId) {
61
+ const stage = activityForNotification(message);
62
+ if (stage && stage !== lastStage) {
63
+ lastStage = stage;
64
+ progressQueue = progressQueue.then(() => onProgress?.(stage));
65
+ void progressQueue.catch(error => {
66
+ progressFailure = error instanceof Error ? error : new Error("Progress delivery failed.");
67
+ this.fail(progressFailure);
68
+ });
69
+ }
70
+ }
71
+ if (message.method === "item/completed" && message.params?.threadId === threadId && message.params.item?.type === "agentMessage") {
72
+ try {
73
+ const result = JSON.parse(message.params.item.text);
74
+ if (["completed", "blocked"].includes(result?.outcome))
75
+ outcome = result.outcome;
76
+ }
77
+ catch { /* Wait for a valid structured final answer. */ }
78
+ }
79
+ if (message.method === "turn/completed" && message.params?.threadId === threadId) {
80
+ if (outcome === "blocked")
81
+ reject(new CodexBlockedError("Essential context is missing; the task is blocked."));
82
+ else if (message.params.turn?.status === "completed" && outcome === "completed")
83
+ resolve();
84
+ else
85
+ reject(new Error("Codex turn did not complete."));
86
+ }
87
+ };
88
+ onFailure = reject;
89
+ abort = () => { this.stop(); reject(new Error("Task stopped before lease expiration.")); };
90
+ this.events.on("notification", onNotification);
91
+ this.events.once("failure", onFailure);
92
+ signal?.addEventListener("abort", abort, { once: true });
93
+ if (signal?.aborted)
94
+ abort();
95
+ });
96
+ // Attach a rejection handler before turn/start to cover an early process exit.
97
+ completed.catch(() => { });
98
+ try {
99
+ await this.request("turn/start", { threadId, cwd: workspace, approvalPolicy: "never", permissions: "dev-orchestrator", input: [{ type: "text", text: prompt }], outputSchema: { type: "object", properties: { outcome: { type: "string", enum: ["completed", "blocked"] } }, required: ["outcome"], additionalProperties: false } });
100
+ await completed;
101
+ await progressQueue;
102
+ if (progressFailure)
103
+ throw progressFailure;
104
+ }
105
+ finally {
106
+ this.events.off("notification", onNotification);
107
+ this.events.off("failure", onFailure);
108
+ signal?.removeEventListener("abort", abort);
109
+ }
110
+ }
111
+ stop() { stopProcess(this.child); this.fail(new Error("Codex stopped.")); }
112
+ request(method, params) {
113
+ if (this.failed)
114
+ return Promise.reject(this.failed);
115
+ return new Promise((resolve, reject) => {
116
+ const id = ++this.id;
117
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error("Codex request timed out.")); this.stop(); }, 30_000);
118
+ this.pending.set(id, { resolve, reject, timer });
119
+ this.send({ id, method, params });
120
+ });
121
+ }
122
+ send(message) { if (!this.child.stdin.destroyed)
123
+ this.child.stdin.write(JSON.stringify(message) + "\n"); }
124
+ consume(chunk) {
125
+ this.buffer += chunk;
126
+ if (this.buffer.length > 1_048_576) {
127
+ this.fail(new Error("Codex message exceeded the size limit."));
128
+ this.stop();
129
+ return;
130
+ }
131
+ let newline;
132
+ while ((newline = this.buffer.indexOf("\n")) >= 0) {
133
+ const line = this.buffer.slice(0, newline);
134
+ this.buffer = this.buffer.slice(newline + 1);
135
+ if (!line.trim())
136
+ continue;
137
+ let message;
138
+ try {
139
+ message = JSON.parse(line);
140
+ }
141
+ catch {
142
+ this.fail(new Error("Invalid Codex protocol response."));
143
+ this.stop();
144
+ return;
145
+ }
146
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
147
+ this.fail(new Error("Invalid Codex protocol response."));
148
+ this.stop();
149
+ return;
150
+ }
151
+ if (message.method === "item/completed") {
152
+ const item = message.params?.item;
153
+ if (item?.type === "commandExecution" && (typeof item.exitCode === "number" && item.exitCode !== 0 || item.status === "failed")) {
154
+ const command = String(item.command ?? "");
155
+ const wrapped = command.trim().match(/^(?:\/[^\s]+\/)?(?:bash|zsh|sh)\s+-lc\s+(['"])([\s\S]*)\1$/);
156
+ const probe = (wrapped?.[2] ?? command).trim();
157
+ const unquoted = probe.replace(/"[^"\n]*"|'[^'\n]*'/g, "");
158
+ const expectedMiss = item.exitCode === 1 && /^(?:rg|grep|test|\[)(?:\s|$)/.test(probe) && !/[;&|]/.test(unquoted);
159
+ if (!expectedMiss) {
160
+ const detail = (command + ":" + String(item.exitCode) + ":" + String(item.aggregatedOutput ?? "")).replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "<id>").replace(/\b\d+(?:\.\d+)?\s*(?:ms|s|seconds)\b/g, "<duration>");
161
+ const fingerprint = createHash("sha256").update(detail).digest("hex");
162
+ const count = (this.commandFailures.get(fingerprint) ?? 0) + 1;
163
+ this.commandFailures.set(fingerprint, count);
164
+ if (count >= 3) {
165
+ this.fail(new RepeatedFailureError(fingerprint));
166
+ this.stop();
167
+ return;
168
+ }
169
+ }
170
+ }
171
+ }
172
+ if (message.method && message.id !== undefined) {
173
+ // The connector never auto-approves a broader permission or user-input request.
174
+ this.send({ id: message.id, error: { code: -32000, message: "Required context or permission is unavailable; task blocked." } });
175
+ this.fail(new Error("Codex needs context or unsupported permissions."));
176
+ this.stop();
177
+ return;
178
+ }
179
+ if (message.id !== undefined) {
180
+ const pending = this.pending.get(message.id);
181
+ if (pending) {
182
+ clearTimeout(pending.timer);
183
+ this.pending.delete(message.id);
184
+ message.error ? pending.reject(new Error("Codex rejected the configured request or sandbox.")) : pending.resolve(message.result);
185
+ }
186
+ }
187
+ else if (message.method)
188
+ this.events.emit("notification", message);
189
+ }
190
+ }
191
+ fail(error) {
192
+ if (this.failed)
193
+ return;
194
+ this.failed = error;
195
+ for (const pending of this.pending.values()) {
196
+ clearTimeout(pending.timer);
197
+ pending.reject(error);
198
+ }
199
+ this.pending.clear();
200
+ this.events.emit("failure", error);
201
+ }
202
+ }
package/dist/config.js ADDED
@@ -0,0 +1,69 @@
1
+ import { lstat, readFile, mkdir, open, rename, unlink } from "node:fs/promises";
2
+ import { dirname, isAbsolute } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ export function serverURL(value) {
5
+ const url = new URL(value);
6
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
7
+ if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) || url.username || url.password || url.search || url.hash || url.pathname !== "/")
8
+ throw new Error("Use an HTTPS server origin (loopback HTTP is allowed for development).");
9
+ return url.origin;
10
+ }
11
+ export async function loadConfig(path) {
12
+ if (!isAbsolute(path))
13
+ throw new Error("Configuration path must be absolute.");
14
+ const info = await lstat(path);
15
+ if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) !== 0)
16
+ throw new Error("Configuration must be a regular owner-only file (chmod 600).");
17
+ const config = JSON.parse(await readFile(path, "utf8"));
18
+ if (![1, 2].includes(config.version) || typeof config.token !== "string" || !config.token)
19
+ throw new Error("Invalid connector configuration.");
20
+ const server = serverURL(config.server);
21
+ if (config.version === 1) {
22
+ if (!Array.isArray(config.mappings))
23
+ throw new Error("Invalid connector configuration.");
24
+ console.warn("Legacy connector configuration detected. Local project mappings are ignored; bind projects in the web app.");
25
+ }
26
+ else if ("mappings" in config) {
27
+ throw new Error("Invalid connector configuration.");
28
+ }
29
+ return { version: 2, server, token: config.token, ...(typeof config.codexBinary === "string" ? { codexBinary: config.codexBinary } : {}) };
30
+ }
31
+ export async function saveConfig(path, config, exclusive = false) {
32
+ if (!isAbsolute(path))
33
+ throw new Error("Configuration path must be absolute.");
34
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
35
+ if (!exclusive) {
36
+ try {
37
+ await loadConfig(path);
38
+ }
39
+ catch (e) {
40
+ if (e.code !== "ENOENT")
41
+ throw e;
42
+ }
43
+ }
44
+ if (exclusive) {
45
+ const file = await open(path, "wx", 0o600);
46
+ try {
47
+ await file.writeFile(JSON.stringify(config, null, 2));
48
+ await file.sync();
49
+ }
50
+ finally {
51
+ await file.close();
52
+ }
53
+ }
54
+ else {
55
+ const temporary = path + ".tmp-" + randomUUID();
56
+ const file = await open(temporary, "wx", 0o600);
57
+ try {
58
+ await file.writeFile(JSON.stringify(config, null, 2));
59
+ await file.sync();
60
+ await file.close();
61
+ await rename(temporary, path);
62
+ }
63
+ catch (error) {
64
+ await file.close().catch(() => { });
65
+ await unlink(temporary).catch(() => { });
66
+ throw error;
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,28 @@
1
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { cleanGitEnv } from "./process.js";
4
+ export function validateGitHubCredential(value) {
5
+ if (!value || typeof value !== "object")
6
+ throw new Error("GitHub authorization is unavailable.");
7
+ const credential = value;
8
+ if (typeof credential.token !== "string" || !/^[A-Za-z0-9_-]+$/.test(credential.token) ||
9
+ typeof credential.expires_at !== "string" || !Number.isFinite(Date.parse(credential.expires_at)) || Date.parse(credential.expires_at) <= Date.now() ||
10
+ typeof credential.author_name !== "string" || !credential.author_name || credential.author_name.length > 200 || /[\r\n]/.test(credential.author_name) ||
11
+ typeof credential.author_email !== "string" || !credential.author_email || credential.author_email.length > 254 || /[\r\n]/.test(credential.author_email)) {
12
+ throw new Error("GitHub authorization is invalid or expired.");
13
+ }
14
+ }
15
+ export async function withGitHubCredential(root, credential, operation) {
16
+ validateGitHubCredential(credential);
17
+ const directory = await mkdtemp(join(root, ".github-auth-"));
18
+ try {
19
+ await chmod(directory, 0o700);
20
+ const helper = join(directory, "askpass");
21
+ const script = `#!/bin/sh\ncase "$1" in\n *sername*) printf '%s\\n' 'x-access-token' ;;\n *assword*) printf '%s\\n' '${credential.token}' ;;\n *) exit 1 ;;\nesac\n`;
22
+ await writeFile(helper, script, { encoding: "utf8", mode: 0o700, flag: "wx" });
23
+ return await operation({ ...cleanGitEnv(), GIT_ASKPASS: helper, SSH_ASKPASS: helper, GIT_TERMINAL_PROMPT: "0" });
24
+ }
25
+ finally {
26
+ await rm(directory, { recursive: true, force: true });
27
+ }
28
+ }
@@ -0,0 +1,51 @@
1
+ import { mkdir, open, readFile, rename, unlink, lstat } from "node:fs/promises";
2
+ import { join, isAbsolute } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ export class Journal {
5
+ root;
6
+ constructor(root) {
7
+ if (!isAbsolute(root))
8
+ throw new Error("Journal directory must be absolute.");
9
+ this.root = root;
10
+ }
11
+ async load() {
12
+ try {
13
+ const path = join(this.root, "active.json");
14
+ const stat = await lstat(path);
15
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077))
16
+ throw new Error("Unsafe journal file.");
17
+ const state = JSON.parse(await readFile(path, "utf8"));
18
+ if (!state.lease?.id || !Number.isInteger(state.sequence))
19
+ throw new Error("Corrupt journal; manual recovery is required.");
20
+ return state;
21
+ }
22
+ catch (error) {
23
+ if (error.code === "ENOENT")
24
+ return null;
25
+ throw error;
26
+ }
27
+ }
28
+ async save(state) {
29
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
30
+ const info = await lstat(this.root);
31
+ if (!info.isDirectory() || info.isSymbolicLink())
32
+ throw new Error("Unsafe journal directory.");
33
+ const temp = join(this.root, ".journal-" + randomUUID());
34
+ const file = await open(temp, "wx", 0o600);
35
+ try {
36
+ await file.writeFile(JSON.stringify(state));
37
+ await file.sync();
38
+ await file.close();
39
+ await rename(temp, join(this.root, "active.json"));
40
+ }
41
+ catch (error) {
42
+ await file.close().catch(() => { });
43
+ await unlink(temp).catch(() => { });
44
+ throw error;
45
+ }
46
+ }
47
+ async clear() {
48
+ await unlink(join(this.root, "active.json")).catch((e) => { if (e.code !== "ENOENT")
49
+ throw e; });
50
+ }
51
+ }
@@ -0,0 +1,54 @@
1
+ import { spawn } from "node:child_process";
2
+ export const cleanGitEnv = () => ({
3
+ PATH: process.env.PATH, HOME: process.env.HOME,
4
+ LANG: "C", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null",
5
+ GIT_TERMINAL_PROMPT: "0", GIT_NO_REPLACE_OBJECTS: "1"
6
+ });
7
+ export async function readAuthor(source, env = { PATH: process.env.PATH, HOME: process.env.HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, GIT_CONFIG_NOSYSTEM: "1" }) {
8
+ // Reading these two values does not execute hooks or credential helpers.
9
+ const name = (await command("git", ["-C", source, "config", "--get", "user.name"], { env })).output;
10
+ const email = (await command("git", ["-C", source, "config", "--get", "user.email"], { env })).output;
11
+ if (!name || name.length > 200 || !email || email.length > 254 || /[\r\n]/.test(name + email))
12
+ throw new Error("Configure a Git author on the customer host before running tasks.");
13
+ return { name, email };
14
+ }
15
+ export function stopProcess(child) {
16
+ if (!child.pid || child.exitCode !== null)
17
+ return;
18
+ try {
19
+ process.kill(-child.pid, "SIGKILL");
20
+ }
21
+ catch {
22
+ child.kill("SIGKILL");
23
+ }
24
+ }
25
+ export async function command(binary, args, opts = {}) {
26
+ return new Promise((resolve, reject) => {
27
+ const child = spawn(binary, args, { cwd: opts.cwd, env: opts.env ?? cleanGitEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
28
+ let output = "", settled = false;
29
+ const fail = () => { if (!settled) {
30
+ settled = true;
31
+ stopProcess(child);
32
+ reject(new Error("Local command failed."));
33
+ } };
34
+ const timer = setTimeout(fail, opts.timeout ?? 30_000);
35
+ const abort = () => fail();
36
+ opts.signal?.addEventListener("abort", abort, { once: true });
37
+ if (opts.signal?.aborted)
38
+ fail();
39
+ child.stdout.on("data", chunk => { output += chunk.toString(); if (output.length > 1_048_576)
40
+ fail(); });
41
+ child.on("error", fail);
42
+ child.on("close", code => {
43
+ clearTimeout(timer);
44
+ opts.signal?.removeEventListener("abort", abort);
45
+ if (settled)
46
+ return;
47
+ settled = true;
48
+ if (code === 0 || (code === 1 && opts.allowExitOne))
49
+ resolve({ output: output.trim(), code: code });
50
+ else
51
+ reject(new Error("Local command failed."));
52
+ });
53
+ });
54
+ }