jules-orchestrator-kit 0.29.0 → 0.31.0

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.
@@ -0,0 +1,278 @@
1
+ import {
2
+ readFileSync,
3
+ mkdirSync,
4
+ existsSync,
5
+ unlinkSync,
6
+ renameSync,
7
+ openSync,
8
+ writeSync,
9
+ fsyncSync,
10
+ closeSync,
11
+ } from "node:fs";
12
+ import { join, dirname, resolve, relative, isAbsolute } from "node:path";
13
+ import { createHash, randomUUID } from "node:crypto";
14
+ import { execFileSync } from "node:child_process";
15
+ import { createOperationReceipt } from "./receipts.mjs";
16
+ import { withVfsMutex } from "../state.mjs";
17
+
18
+ /**
19
+ * @typedef {import("../ux/capabilities.mjs").TerminalSessionOptions} TerminalSessionOptions
20
+ */
21
+
22
+ /**
23
+ * Calculate file SHA-256 hash.
24
+ * @param {string} filePath
25
+ * @returns {string | null}
26
+ */
27
+ function getFileHash(filePath) {
28
+ if (!existsSync(filePath)) return null;
29
+ try {
30
+ const data = readFileSync(filePath);
31
+ return "sha256:" + createHash("sha256").update(data).digest("hex");
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function resolveWithinRoot(root, candidate, label) {
38
+ if (typeof candidate !== "string" || !candidate || candidate.includes("\0")) {
39
+ throw new Error(`Invalid ${label} path`);
40
+ }
41
+ const targetPath = resolve(root, candidate);
42
+ const rel = relative(resolve(root), targetPath);
43
+ if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)) {
44
+ throw new Error(`${label} path escapes repository root: ${candidate}`);
45
+ }
46
+ return targetPath;
47
+ }
48
+
49
+ function atomicWriteFile(filePath, content) {
50
+ const tmpPath = `${filePath}.${randomUUID()}.tmp`;
51
+ let fd;
52
+ try {
53
+ fd = openSync(tmpPath, "wx", 0o600);
54
+ writeSync(fd, content, "utf-8");
55
+ fsyncSync(fd);
56
+ closeSync(fd);
57
+ fd = undefined;
58
+ renameSync(tmpPath, filePath);
59
+ } catch (err) {
60
+ if (fd !== undefined) {
61
+ try { closeSync(fd); } catch (_) {}
62
+ }
63
+ try { unlinkSync(tmpPath); } catch (_) {}
64
+ throw err;
65
+ }
66
+ }
67
+
68
+ function throwIfAborted(signal) {
69
+ if (signal?.aborted) {
70
+ throw signal.reason || new Error("Action plan application aborted");
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Verify preconditions of an ActionPlan against target repository state.
76
+ * @param {import("../ux/capabilities.mjs").ActionPlan} plan
77
+ * @param {string} root
78
+ */
79
+ export function verifyPreconditions(plan, root) {
80
+ for (const cond of plan.preconditions || []) {
81
+ if (cond.kind === "file-hash") {
82
+ const targetPath = resolveWithinRoot(root, cond.target, "Precondition target");
83
+ const actualHash = getFileHash(targetPath);
84
+ if (actualHash !== cond.expected) {
85
+ throw new Error(
86
+ `Precondition failed [file-hash]: ${cond.target} expected ${cond.expected}, received ${actualHash}`
87
+ );
88
+ }
89
+ } else if (cond.kind === "lock-free") {
90
+ const lockTarget = String(cond.target || "");
91
+ const lockBase = resolveWithinRoot(root, join(".agent", "state", "locks", lockTarget), "Lock target");
92
+ const lockPaths = [`${lockBase}.json`, `${lockBase}.lock`];
93
+ if (lockPaths.some((lockPath) => existsSync(lockPath))) {
94
+ throw new Error(`Precondition failed [lock-free]: Lock ${cond.target} is active.`);
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Execute an ActionPlan within a transactional boundaries (atomic apply + rollback).
102
+ * @param {any} plan
103
+ * @param {Object} options
104
+ * @param {string} options.root
105
+ * @param {AbortSignal} [options.signal]
106
+ * @returns {Promise<import("./receipts.mjs").OperationReceipt>}
107
+ */
108
+ export async function applyActionPlan(plan, options = {}) {
109
+ const root = resolve(options.root || process.cwd());
110
+ const mutexDir = join(root, ".agent", "state", ".action-plan.mutex");
111
+ mkdirSync(dirname(mutexDir), { recursive: true });
112
+ return withVfsMutex(mutexDir, () => applyActionPlanLocked(plan, { ...options, root }));
113
+ }
114
+
115
+ async function applyActionPlanLocked(plan, options) {
116
+ const root = options.root;
117
+ const startTime = Date.now();
118
+ throwIfAborted(options.signal);
119
+
120
+ // 1. Verify Preconditions
121
+ verifyPreconditions(plan, root);
122
+
123
+ // 2. Prepare Intent Journaling
124
+ if (!plan || typeof plan.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(plan.id)) {
125
+ throw new Error("Invalid action plan id");
126
+ }
127
+ const journalDir = join(root, ".agent", "state", "journal");
128
+ if (!existsSync(journalDir)) {
129
+ mkdirSync(journalDir, { recursive: true });
130
+ }
131
+ const journalPath = join(journalDir, `${plan.id}.intent.json`);
132
+ atomicWriteFile(journalPath, JSON.stringify({ plan, startedAt: new Date().toISOString() }, null, 2));
133
+
134
+ /** @type {Array<{ path: string, originalContent: string | null, operation?: string, fromPath?: string, sourceContent?: string }>} */
135
+ const rollbackStack = [];
136
+ const mutationsApplied = [];
137
+ const effectsExecuted = [];
138
+
139
+ try {
140
+ // 3. Apply File Mutations
141
+ for (const mutation of plan.fileMutations || []) {
142
+ throwIfAborted(options.signal);
143
+ const targetPath = resolveWithinRoot(root, mutation.path, "Mutation target");
144
+
145
+ if (mutation.operation === "move") {
146
+ const sourcePath = resolveWithinRoot(root, mutation.fromPath, "Move source");
147
+ if (sourcePath === targetPath) {
148
+ throw new Error(`Move source and target are identical: ${mutation.path}`);
149
+ }
150
+ if (!existsSync(sourcePath)) {
151
+ throw new Error(`Move source does not exist: ${mutation.fromPath}`);
152
+ }
153
+ if (existsSync(targetPath)) {
154
+ throw new Error(`Move target already exists: ${mutation.path}`);
155
+ }
156
+ const sourceContent = readFileSync(sourcePath, "utf-8");
157
+ const sourceHash = "sha256:" + createHash("sha256").update(sourceContent).digest("hex");
158
+ mkdirSync(dirname(targetPath), { recursive: true });
159
+ rollbackStack.push({
160
+ path: targetPath,
161
+ originalContent: null,
162
+ operation: "move",
163
+ fromPath: sourcePath,
164
+ sourceContent,
165
+ });
166
+ renameSync(sourcePath, targetPath);
167
+ mutationsApplied.push({ path: mutation.path, operation: "move", hashBefore: sourceHash });
168
+ continue;
169
+ }
170
+
171
+ if (!["delete", "create", "replace"].includes(mutation.operation)) {
172
+ throw new Error(`Unsupported file mutation operation: ${mutation.operation}`);
173
+ }
174
+
175
+ const parentDir = dirname(targetPath);
176
+ if (!existsSync(parentDir)) {
177
+ mkdirSync(parentDir, { recursive: true });
178
+ }
179
+
180
+ const existingContent = existsSync(targetPath) ? readFileSync(targetPath, "utf-8") : null;
181
+ const hashBefore = existingContent !== null
182
+ ? "sha256:" + createHash("sha256").update(existingContent).digest("hex")
183
+ : undefined;
184
+
185
+ if (mutation.operation === "create" && existingContent !== null) {
186
+ throw new Error(`Create target already exists: ${mutation.path}`);
187
+ }
188
+ rollbackStack.push({ path: targetPath, originalContent: existingContent, operation: mutation.operation });
189
+
190
+ if (mutation.operation === "delete") {
191
+ if (existsSync(targetPath)) {
192
+ unlinkSync(targetPath);
193
+ }
194
+ mutationsApplied.push({ path: mutation.path, operation: "delete", hashBefore });
195
+ } else {
196
+ const newContent = mutation.newContent ?? "";
197
+ atomicWriteFile(targetPath, newContent);
198
+
199
+ const hashAfter = "sha256:" + createHash("sha256").update(newContent).digest("hex");
200
+ mutationsApplied.push({ path: mutation.path, operation: mutation.operation, hashBefore, hashAfter });
201
+ }
202
+ }
203
+
204
+ // 4. Execute Command Effects
205
+ for (const effect of plan.commandEffects || []) {
206
+ throwIfAborted(options.signal);
207
+ const execPath = effect.executable;
208
+ const args = Array.isArray(effect.args) ? effect.args : [];
209
+ const cwd = effect.cwd ? resolveWithinRoot(root, effect.cwd, "Command working directory") : root;
210
+
211
+ try {
212
+ execFileSync(execPath, args, {
213
+ cwd,
214
+ timeout: effect.timeoutMs || 30000,
215
+ env: process.env,
216
+ });
217
+ effectsExecuted.push({ executable: execPath, args, exitCode: 0 });
218
+ } catch (err) {
219
+ const exitCode = typeof err.status === "number" ? err.status : 1;
220
+ effectsExecuted.push({ executable: execPath, args, exitCode });
221
+ throw new Error(`Command effect failed (${execPath} ${args.join(" ")}): exit code ${exitCode}`);
222
+ }
223
+ }
224
+
225
+ // 5. Clean up Journal & Record Success Receipt
226
+ if (existsSync(journalPath)) {
227
+ unlinkSync(journalPath);
228
+ }
229
+
230
+ const receipt = createOperationReceipt(root, {
231
+ actionPlanId: plan.id,
232
+ kind: plan.kind,
233
+ title: plan.title,
234
+ repository: plan.repository || "local",
235
+ status: "success",
236
+ durationMs: Date.now() - startTime,
237
+ mutationsApplied,
238
+ effectsExecuted,
239
+ });
240
+
241
+ return receipt;
242
+ } catch (err) {
243
+ // Execute Rollback Stack in reverse order
244
+ for (let i = rollbackStack.length - 1; i >= 0; i--) {
245
+ const mutation = rollbackStack[i];
246
+ try {
247
+ if (mutation.operation === "move") {
248
+ if (existsSync(mutation.path) && !existsSync(mutation.fromPath)) {
249
+ renameSync(mutation.path, mutation.fromPath);
250
+ }
251
+ } else if (mutation.originalContent === null) {
252
+ if (existsSync(mutation.path)) unlinkSync(mutation.path);
253
+ } else {
254
+ atomicWriteFile(mutation.path, mutation.originalContent);
255
+ }
256
+ } catch {
257
+ // Best-effort rollback
258
+ }
259
+ }
260
+
261
+ if (existsSync(journalPath)) {
262
+ try { unlinkSync(journalPath); } catch (_) {}
263
+ }
264
+
265
+ const failedReceipt = createOperationReceipt(root, {
266
+ actionPlanId: plan.id,
267
+ kind: plan.kind,
268
+ title: plan.title,
269
+ repository: plan.repository || "local",
270
+ status: "rolled-back",
271
+ durationMs: Date.now() - startTime,
272
+ mutationsApplied,
273
+ effectsExecuted,
274
+ });
275
+
276
+ throw new Error(`Action plan execution failed and was rolled back: ${err.message}. Receipt: ${failedReceipt.receiptId}`);
277
+ }
278
+ }
package/src/provider.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { redactSecrets } from "./security.mjs";
2
3
 
3
4
  export const JULES_PRESET = {
4
5
  name: "jules",
@@ -73,7 +74,7 @@ export class ProviderSchemaError extends Error {
73
74
  export function parseRetryAfter(header) {
74
75
  if (!header) return null;
75
76
  const seconds = Number(header);
76
- if (!isNaN(seconds)) {
77
+ if (Number.isFinite(seconds)) {
77
78
  return Math.max(0, Math.round(seconds * 1000));
78
79
  }
79
80
  const dateMs = Date.parse(header);
@@ -110,12 +111,20 @@ export function createProvider(spec = "jules", config = {}) {
110
111
  }
111
112
  const providerSpec = typeof spec === "string"
112
113
  ? (spec === "claude-code" ? CLAUDE_PRESET : spec === "codex" ? CODEX_PRESET : JULES_PRESET)
113
- : spec;
114
+ : spec || JULES_PRESET;
115
+
116
+ if (!providerSpec || typeof providerSpec !== "object") {
117
+ throw new TypeError("Provider specification must be a string or object");
118
+ }
114
119
 
115
120
  return {
116
121
  name: providerSpec.name || "custom-provider",
117
122
 
118
- async dispatch(task, ctx = {}) {
123
+ async dispatch(task = {}, ctx = {}) {
124
+ if (!task || typeof task !== "object") {
125
+ throw new TypeError("Provider task must be an object");
126
+ }
127
+ if (!ctx || typeof ctx !== "object") ctx = {};
119
128
  const rawToken = process.env.JULES_API_KEY || (ctx.allowLegacyKey ? process.env.GEMINI_API_KEY : "") || "";
120
129
  if (!rawToken && !ctx.dryRun && providerSpec.name === "jules") {
121
130
  throw new MissingApiKeyError();
@@ -195,7 +204,8 @@ export function createProvider(spec = "jules", config = {}) {
195
204
  body = JSON.stringify(bodyObj);
196
205
  }
197
206
 
198
- const timeoutMs = ctx.timeoutMs || config.timeoutMs || providerSpec.timeoutMs || 120_000;
207
+ const requestedTimeout = Number(ctx.timeoutMs ?? config.timeoutMs ?? providerSpec.timeoutMs ?? 120_000);
208
+ const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 ? requestedTimeout : 120_000;
199
209
  let res;
200
210
  try {
201
211
  res = await fetch(url, {
@@ -216,7 +226,7 @@ export function createProvider(spec = "jules", config = {}) {
216
226
  if (!res.ok) {
217
227
  const text = await res.text();
218
228
  const cleanText = text.slice(0, 500);
219
- const sanitizedText = rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText;
229
+ const sanitizedText = redactSecrets(rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText);
220
230
  const retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
221
231
  const retryAfterMs = parseRetryAfter(retryAfterHeader);
222
232
 
@@ -282,7 +292,7 @@ export function createProvider(spec = "jules", config = {}) {
282
292
  throw new Error(`Provider Exec Failed: ${res.error.message}`);
283
293
  }
284
294
  if (res.status !== 0) {
285
- const stderr = (res.stderr || "").slice(0, 500);
295
+ const stderr = redactSecrets((res.stderr || "").slice(0, 500));
286
296
  throw new Error(`Provider Exec Exit ${res.status}: ${stderr}`);
287
297
  }
288
298
 
@@ -301,6 +311,124 @@ export function createProvider(spec = "jules", config = {}) {
301
311
  if (providerSpec.type === "exec" && !providerSpec.command) return false;
302
312
  return true;
303
313
  },
314
+
315
+ async resume(sessionId, prompt = "", ctx = {}, task = null) {
316
+ if (!sessionId || typeof sessionId !== "string") {
317
+ throw new TypeError("resume() requires a valid sessionId string");
318
+ }
319
+ if (!ctx || typeof ctx !== "object") ctx = {};
320
+
321
+ const rawToken = process.env.JULES_API_KEY || (ctx.allowLegacyKey ? process.env.GEMINI_API_KEY : "") || "";
322
+ if (!rawToken && !ctx.dryRun && providerSpec.name === "jules") {
323
+ throw new MissingApiKeyError();
324
+ }
325
+
326
+ if (ctx.dryRun) {
327
+ return {
328
+ id: sessionId,
329
+ status: "pending",
330
+ resumed: true,
331
+ provider: providerSpec.name,
332
+ prompt: redactSecrets(prompt),
333
+ };
334
+ }
335
+
336
+ if (providerSpec.type === "http") {
337
+ const sendMessageUrlTemplate = providerSpec.sendMessageUrl || `${providerSpec.url}/${sessionId}:sendMessage`;
338
+ const urlData = { sessionId, token: rawToken, ...ctx };
339
+ const url = interpolateString(sendMessageUrlTemplate, urlData);
340
+
341
+ const headers = {};
342
+ for (const [k, v] of Object.entries(providerSpec.headers || {})) {
343
+ const val = interpolateString(v, urlData);
344
+ if (val.includes("\r") || val.includes("\n")) {
345
+ throw new Error(`CRITICAL: Header injection attempt detected in header "${k}"`);
346
+ }
347
+ headers[k] = val;
348
+ }
349
+
350
+ const bodyObj = { prompt: prompt };
351
+ const body = JSON.stringify(bodyObj);
352
+
353
+ const requestedTimeout = Number(ctx.timeoutMs ?? config.timeoutMs ?? providerSpec.timeoutMs ?? 120_000);
354
+ const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 ? requestedTimeout : 120_000;
355
+ let res;
356
+ try {
357
+ res = await fetch(url, {
358
+ method: "POST",
359
+ headers,
360
+ body,
361
+ signal: AbortSignal.timeout(timeoutMs),
362
+ });
363
+ } catch (err) {
364
+ if (err.name === "TimeoutError" || err.name === "AbortError" || err.code === "ABORT_ERR") {
365
+ throw new ProviderUnavailableError(`Provider HTTP Timeout (${timeoutMs}ms): ${err.message}`, {
366
+ status: 504,
367
+ });
368
+ }
369
+ throw err;
370
+ }
371
+
372
+ if (!res.ok) {
373
+ const text = await res.text();
374
+ const cleanText = text.slice(0, 500);
375
+ const sanitizedText = redactSecrets(rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText);
376
+
377
+ // Fail-soft fallback: if remote session is closed/expired (400 or 404) and task object is available, fall back to cold dispatch
378
+ if ((res.status === 400 || res.status === 404) && task && typeof task === "object") {
379
+ const fallbackRes = await this.dispatch(task, { ...ctx, warmResumptionFailed: true });
380
+ return {
381
+ ...fallbackRes,
382
+ _warmFallback: true,
383
+ _warmErrorStatus: res.status,
384
+ };
385
+ }
386
+
387
+ const retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
388
+ const retryAfterMs = parseRetryAfter(retryAfterHeader);
389
+
390
+ if (res.status === 429) {
391
+ throw new ProviderRateLimitError(`Provider HTTP Error (429): ${sanitizedText}`, {
392
+ retryAfterMs: retryAfterMs ?? 60000,
393
+ status: 429,
394
+ });
395
+ }
396
+
397
+ if (res.status >= 500 && res.status < 600) {
398
+ throw new ProviderUnavailableError(`Provider HTTP Error (${res.status}): ${sanitizedText}`, {
399
+ status: res.status,
400
+ retryAfterMs: retryAfterMs ?? undefined,
401
+ });
402
+ }
403
+
404
+ const err = new Error(`Provider HTTP Error (${res.status}): ${sanitizedText}`);
405
+ err.status = res.status;
406
+ throw err;
407
+ }
408
+
409
+ let json;
410
+ try {
411
+ json = await res.json();
412
+ } catch (err) {
413
+ throw new ProviderSchemaError(`Provider Payload Error: Invalid JSON response: ${err.message}`, {
414
+ status: res.status,
415
+ });
416
+ }
417
+
418
+ return {
419
+ id: json.id || json.name || sessionId,
420
+ status: json.state || "active",
421
+ resumed: true,
422
+ raw: json,
423
+ };
424
+ }
425
+
426
+ if (providerSpec.type === "exec") {
427
+ return this.dispatch({ ...(task || {}), prompt }, ctx);
428
+ }
429
+
430
+ throw new Error(`Unsupported provider type: ${providerSpec.type}`);
431
+ },
304
432
  };
305
433
  }
306
434
 
@@ -332,7 +460,35 @@ export function createFailoverProvider(providers = ["jules"], config = {}) {
332
460
  err.status === 429;
333
461
 
334
462
  if (i === providerList.length - 1 || !isRecoverable) {
335
- err._failoverErrors = errors;
463
+ if (err && typeof err === "object") {
464
+ err._failoverErrors = errors;
465
+ }
466
+ throw err;
467
+ }
468
+ }
469
+ }
470
+ },
471
+
472
+ async resume(sessionId, prompt, ctx = {}, task = null) {
473
+ const errors = [];
474
+ for (let i = 0; i < providerList.length; i++) {
475
+ const provider = providerList[i];
476
+ if (typeof provider.resume !== "function") continue;
477
+ try {
478
+ const res = await provider.resume(sessionId, prompt, ctx, task);
479
+ return { ...res, _routedProvider: provider.name, _failoverAttempts: i };
480
+ } catch (err) {
481
+ errors.push({ provider: provider.name, error: err });
482
+ const isRecoverable =
483
+ err instanceof ProviderRateLimitError ||
484
+ err instanceof ProviderUnavailableError ||
485
+ (err.status && err.status >= 500 && err.status < 600) ||
486
+ err.status === 429;
487
+
488
+ if (i === providerList.length - 1 || !isRecoverable) {
489
+ if (err && typeof err === "object") {
490
+ err._failoverErrors = errors;
491
+ }
336
492
  throw err;
337
493
  }
338
494
  }
@@ -340,7 +496,7 @@ export function createFailoverProvider(providers = ["jules"], config = {}) {
340
496
  },
341
497
 
342
498
  validate() {
343
- return providerList.every((p) => p.validate());
499
+ return providerList.every((p) => typeof p.validate !== "function" || p.validate());
344
500
  },
345
501
  };
346
502
  }
package/src/security.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync } from "node:fs";
1
+ import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join, basename } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { normalizePath } from "./config.mjs";
@@ -13,7 +13,7 @@ export const HIGH_CONFIDENCE_PATTERNS = [
13
13
 
14
14
  /\bAKIA[0-9A-Z]{16}\b/g,
15
15
  /\bASIA[0-9A-Z]{16}\b/g,
16
- /\baws_secret_access_key\s*=\s*['"]?[A-Za-z0-9\/+=]{40}['"]?/g,
16
+ /\baws_secret_access_key\s*=\s*['"]?[A-Za-z0-9\/+=]{40}['"]?/gi,
17
17
 
18
18
  /-----BEGIN (?:RSA|DSA|EC|OPENSSH|PRIVATE)(?:\s+PRIVATE)? KEY-----[\s\S]*?-----END (?:RSA|DSA|EC|OPENSSH|PRIVATE)(?:\s+PRIVATE)? KEY-----/g,
19
19
  /PuTTY-User-Key-File-[0-9]:[^\n]+/g,
@@ -61,18 +61,26 @@ export function safeAtomicWrite(filePath, content, options = {}) {
61
61
  const dir = dirname(filePath);
62
62
  const tempFile = join(dir, `.tmp-${basename(filePath)}-${randomBytes(6).toString("hex")}`);
63
63
 
64
- const fd = openSync(tempFile, "wx", mode);
64
+ let fd;
65
65
  try {
66
+ fd = openSync(tempFile, "wx", mode);
66
67
  writeSync(fd, content, null, encoding);
67
68
  if (options.sync !== false) {
68
69
  fsyncSync(fd);
69
70
  }
70
- } finally {
71
71
  closeSync(fd);
72
+ fd = undefined;
73
+ renameSync(tempFile, filePath);
74
+ return true;
75
+ } catch (err) {
76
+ if (fd !== undefined) {
77
+ try { closeSync(fd); } catch (_) {}
78
+ }
79
+ try {
80
+ if (existsSync(tempFile)) unlinkSync(tempFile);
81
+ } catch (_) {}
82
+ throw err;
72
83
  }
73
-
74
- renameSync(tempFile, filePath);
75
- return true;
76
84
  }
77
85
 
78
86
  export function shannonEntropy(str) {
@@ -119,7 +119,10 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
119
119
  return { ...container, stack: "make", testCmd: "make test", buildCmd: "make build", triggerFile: "Makefile" };
120
120
  }
121
121
 
122
- // 5. Python / Elixir / Ruby / Java
122
+ // 5. Python / Django / Elixir / Ruby / Java
123
+ if (existsSync(join(projectRoot, "manage.py"))) {
124
+ return { ...container, stack: "django", testCmd: "python manage.py test --keepdb", buildCmd: "python manage.py check", triggerFile: "manage.py" };
125
+ }
123
126
  if (existsSync(join(projectRoot, "pyproject.toml")) || existsSync(join(projectRoot, "requirements.txt")) || existsSync(join(projectRoot, "setup.py"))) {
124
127
  const triggerFile = existsSync(join(projectRoot, "pyproject.toml")) ? "pyproject.toml" : existsSync(join(projectRoot, "requirements.txt")) ? "requirements.txt" : "setup.py";
125
128
  return { ...container, stack: "python", testCmd: "pytest", buildCmd: "python3 -m compileall -q .", triggerFile };
@@ -138,14 +141,21 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
138
141
  return { ...container, stack: "gradle", testCmd: "./gradlew test", buildCmd: "./gradlew assemble", triggerFile };
139
142
  }
140
143
 
141
- // 6. JS / TS Runtimes
144
+ // 6. JS / TS Runtimes & ORMs (Prisma, Drizzle)
145
+ let setupCmd = "";
146
+ if (existsSync(join(projectRoot, "prisma", "schema.prisma"))) {
147
+ setupCmd = "npx prisma db push --schema=prisma/schema.prisma";
148
+ } else if (existsSync(join(projectRoot, "drizzle.config.ts")) || existsSync(join(projectRoot, "drizzle.config.js"))) {
149
+ setupCmd = "npx drizzle-kit push";
150
+ }
151
+
142
152
  if (existsSync(join(projectRoot, "bunfig.toml")) || existsSync(join(projectRoot, "bun.lockb"))) {
143
153
  const triggerFile = existsSync(join(projectRoot, "bunfig.toml")) ? "bunfig.toml" : "bun.lockb";
144
- return { ...container, stack: "bun", testCmd: "bun test", buildCmd: "bun run build", triggerFile };
154
+ return { ...container, stack: "bun", setupCmd, testCmd: "bun test", buildCmd: "bun run build", triggerFile };
145
155
  }
146
156
  if (existsSync(join(projectRoot, "deno.json")) || existsSync(join(projectRoot, "deno.jsonc"))) {
147
157
  const triggerFile = existsSync(join(projectRoot, "deno.json")) ? "deno.json" : "deno.jsonc";
148
- return { ...container, stack: "deno", testCmd: "deno test", buildCmd: "deno task build", triggerFile };
158
+ return { ...container, stack: "deno", setupCmd, testCmd: "deno test", buildCmd: "deno task build", triggerFile };
149
159
  }
150
160
  if (existsSync(join(projectRoot, "package.json"))) {
151
161
  let testScript = "npm test";
@@ -161,7 +171,7 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
161
171
  testScript = "npm test";
162
172
  buildScript = "npm run build";
163
173
  }
164
- return { ...container, stack: "node", testCmd: testScript, buildCmd: buildScript, triggerFile: "package.json" };
174
+ return { ...container, stack: "node", setupCmd, testCmd: testScript, buildCmd: buildScript, triggerFile: "package.json" };
165
175
  }
166
176
 
167
177
  // 7. .NET / C# / F# / PHP / Python root file extension fallback