@programinglive/dev-workflow-mcp-server 1.13.12 → 1.13.14
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/factory-client.js +69 -14
- package/factory-credentials.js +156 -0
- package/git-facade.js +2 -0
- package/git-helpers.js +12 -0
- package/index.js +16 -0
- package/package.json +2 -2
- package/process-lock.js +134 -0
- package/tools/handler-modules/commit-handlers.js +44 -4
- package/tools/handler-modules/release-handlers.js +95 -1
- package/workflow-state.js +13 -0
package/factory-client.js
CHANGED
|
@@ -25,6 +25,7 @@ import os from "os";
|
|
|
25
25
|
import { assertSafeKnowledge } from './security/policy.js';
|
|
26
26
|
import { sanitize } from './security/sanitizer.js';
|
|
27
27
|
import { registerRuntimeSecret } from './security/runtime-secrets.js';
|
|
28
|
+
import { refreshFactoryCredentials, describeLoginFailure } from './factory-credentials.js';
|
|
28
29
|
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
29
30
|
import path from "path";
|
|
30
31
|
|
|
@@ -397,6 +398,27 @@ export function buildWorkFinishPayload({ sessionId, deviceId, outcome, summary,
|
|
|
397
398
|
// HTTP layer
|
|
398
399
|
// ---------------------------------------------------------------------------
|
|
399
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Kapan proses MCP ini hidup.
|
|
403
|
+
*
|
|
404
|
+
* Dipakai di pesan gagal login: tanpa ini pengguna tidak punya cara tahu bahwa
|
|
405
|
+
* environment yang dipakai sudah usang.
|
|
406
|
+
*/
|
|
407
|
+
function processStartedAt() {
|
|
408
|
+
return new Date(Date.now() - process.uptime() * 1000);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Satu percobaan login dengan pasangan email/password tertentu. */
|
|
412
|
+
function attemptLogin(config, fetchImpl, { email, password }) {
|
|
413
|
+
registerRuntimeSecret(password);
|
|
414
|
+
|
|
415
|
+
return fetchImpl(`${config.pocketBaseUrl}/api/collections/activity_users/auth-with-password`, {
|
|
416
|
+
method: "POST",
|
|
417
|
+
headers: { "content-type": "application/json" },
|
|
418
|
+
body: JSON.stringify({ identity: email, password }),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
400
422
|
async function resolveToken(config, fetchImpl = fetch) {
|
|
401
423
|
registerRuntimeSecret(config.token); registerRuntimeSecret(config.password);
|
|
402
424
|
if (config.token) return config.token;
|
|
@@ -405,24 +427,57 @@ async function resolveToken(config, fetchImpl = fetch) {
|
|
|
405
427
|
const cached = tokenCache.get(key);
|
|
406
428
|
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
|
407
429
|
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
430
|
+
const remember = (token) => {
|
|
431
|
+
registerRuntimeSecret(token);
|
|
432
|
+
tokenCache.set(key, { token, expiresAt: Date.now() + TOKEN_TTL_MS });
|
|
433
|
+
return token;
|
|
434
|
+
};
|
|
413
435
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
);
|
|
436
|
+
const response = await attemptLogin(config, fetchImpl, config);
|
|
437
|
+
|
|
438
|
+
if (response.ok) {
|
|
439
|
+
const body = await response.json();
|
|
440
|
+
if (!body.token) throw new Error("Factory login returned no token.");
|
|
441
|
+
return remember(body.token);
|
|
418
442
|
}
|
|
419
443
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
444
|
+
// Kredensial ditolak. Sebelum menyerah, baca ulang berkas konfigurasi: proses
|
|
445
|
+
// MCP mewarisi environment saat dijalankan dan tidak pernah memuat ulang, jadi
|
|
446
|
+
// password yang baru diubah di opencode.json tidak pernah terpakai sampai
|
|
447
|
+
// OpenCode di-restart. 31 Oktober 2026 itu mematikan otomasi berjam-jam.
|
|
448
|
+
const refreshed = refreshFactoryCredentials(config);
|
|
449
|
+
|
|
450
|
+
if (refreshed && refreshed.changed) {
|
|
451
|
+
if (refreshed.token) {
|
|
452
|
+
return remember(refreshed.token);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (refreshed.email && refreshed.password) {
|
|
456
|
+
const retry = await attemptLogin(config, fetchImpl, refreshed);
|
|
457
|
+
|
|
458
|
+
if (retry.ok) {
|
|
459
|
+
const body = await retry.json();
|
|
460
|
+
if (!body.token) throw new Error("Factory login returned no token.");
|
|
461
|
+
return remember(body.token);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
throw new Error(
|
|
465
|
+
describeLoginFailure({
|
|
466
|
+
status: retry.status,
|
|
467
|
+
processStartedAt: processStartedAt(),
|
|
468
|
+
credentialsChanged: true,
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
423
473
|
|
|
424
|
-
|
|
425
|
-
|
|
474
|
+
throw new Error(
|
|
475
|
+
describeLoginFailure({
|
|
476
|
+
status: response.status,
|
|
477
|
+
processStartedAt: processStartedAt(),
|
|
478
|
+
credentialsChanged: Boolean(refreshed && refreshed.changed),
|
|
479
|
+
}),
|
|
480
|
+
);
|
|
426
481
|
}
|
|
427
482
|
|
|
428
483
|
/** Test helper: forget cached tokens (for example after a password change). */
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Membaca ulang kredensial Factory dari berkas konfigurasi OpenCode.
|
|
3
|
+
*
|
|
4
|
+
* Kenapa perlu: proses MCP mewarisi environment saat ia di-spawn dan tidak
|
|
5
|
+
* pernah memuat ulang. 31 Oktober 2026 otomasi Factory mati berjam-jam dengan
|
|
6
|
+
* pesan "login failed (HTTP 400). Check FACTORY_EMAIL and FACTORY_PASSWORD"
|
|
7
|
+
* padahal password di `~/.config/opencode/opencode.json` sudah benar dan login
|
|
8
|
+
* langsung ke PocketBase menjawab 200. Penyebabnya: proses MCP hidup sejak
|
|
9
|
+
* 30 Oktober dan memegang password LAMA di memorinya.
|
|
10
|
+
*
|
|
11
|
+
* Modul ini TIDAK menyimpan rahasia baru ke disk. Ia hanya membaca berkas yang
|
|
12
|
+
* sudah ada dan sudah berisi rahasia itu.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "fs";
|
|
16
|
+
import os from "os";
|
|
17
|
+
import path from "path";
|
|
18
|
+
|
|
19
|
+
/** Nama server di blok `mcp` pada konfigurasi OpenCode. */
|
|
20
|
+
export const DEFAULT_SERVER_NAME = "dev-workflow";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Berkas kredensial bawaan: konfigurasi OpenCode.
|
|
24
|
+
*
|
|
25
|
+
* Bisa diarahkan lewat `FACTORY_CREDENTIALS_FILE` bila konfigurasinya di tempat
|
|
26
|
+
* lain (misalnya berkas khusus tanpa isi lain).
|
|
27
|
+
*/
|
|
28
|
+
export function defaultCredentialsFile(env = process.env) {
|
|
29
|
+
const explicit = clean(env.FACTORY_CREDENTIALS_FILE);
|
|
30
|
+
if (explicit) return explicit;
|
|
31
|
+
|
|
32
|
+
const home = clean(env.HOME) || clean(env.USERPROFILE) || os.homedir();
|
|
33
|
+
const configHome = clean(env.XDG_CONFIG_HOME) || path.join(home, ".config");
|
|
34
|
+
|
|
35
|
+
return path.join(configHome, "opencode", "opencode.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clean(value) {
|
|
39
|
+
return typeof value === "string" ? value.trim() : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mengambil blok `environment` milik server MCP kita dari teks konfigurasi.
|
|
44
|
+
*
|
|
45
|
+
* Dua bentuk didukung, karena keduanya ada di lapangan:
|
|
46
|
+
* { mcp: { "<nama>": {...} } } (konfigurasi lama)
|
|
47
|
+
* { mcp: { servers: { "<nama>": {...} } } } (konfigurasi OpenCode di mesin ini)
|
|
48
|
+
*
|
|
49
|
+
* Mengembalikan null bila teks bukan JSON, tidak punya `mcp`, atau tidak punya
|
|
50
|
+
* blok server yang diminta. Mengambil server LAIN akan lebih buruk daripada
|
|
51
|
+
* tidak mengambil apa pun: kredensial proyek lain bisa terpakai.
|
|
52
|
+
*/
|
|
53
|
+
export function extractFactoryEnv(text, { serverName = DEFAULT_SERVER_NAME } = {}) {
|
|
54
|
+
let parsed;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mcp = parsed && parsed.mcp;
|
|
63
|
+
if (!mcp || typeof mcp !== "object") return null;
|
|
64
|
+
|
|
65
|
+
const block = mcp[serverName] || (mcp.servers && mcp.servers[serverName]);
|
|
66
|
+
if (!block || typeof block !== "object") return null;
|
|
67
|
+
|
|
68
|
+
const environment = block.environment;
|
|
69
|
+
if (!environment || typeof environment !== "object") return null;
|
|
70
|
+
|
|
71
|
+
return environment;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Membandingkan kredensial yang sedang dipakai proses dengan yang ada di berkas.
|
|
76
|
+
*
|
|
77
|
+
* Mengembalikan null bila tidak ada yang bisa disegarkan (berkas tidak ada,
|
|
78
|
+
* rusak, atau tanpa blok server kita). Selain itu objek dengan kredensial
|
|
79
|
+
* TERBARU, plus `changed` yang menandakan apakah ia berbeda dari yang dipakai.
|
|
80
|
+
*/
|
|
81
|
+
export function refreshFactoryCredentials(
|
|
82
|
+
config = {},
|
|
83
|
+
{ env = process.env, readFileImpl = readFileSync, serverName = DEFAULT_SERVER_NAME } = {}
|
|
84
|
+
) {
|
|
85
|
+
const file = defaultCredentialsFile(env);
|
|
86
|
+
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
text = String(readFileImpl(file, "utf8"));
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const environment = extractFactoryEnv(text, { serverName });
|
|
95
|
+
if (!environment) return null;
|
|
96
|
+
|
|
97
|
+
const token = clean(environment.FACTORY_TOKEN);
|
|
98
|
+
const email = clean(environment.FACTORY_EMAIL);
|
|
99
|
+
const password = clean(environment.FACTORY_PASSWORD);
|
|
100
|
+
|
|
101
|
+
const current = {
|
|
102
|
+
token: clean(config.token),
|
|
103
|
+
email: clean(config.email),
|
|
104
|
+
password: clean(config.password),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const changed =
|
|
108
|
+
token !== current.token || email !== current.email || password !== current.password;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
token,
|
|
112
|
+
email,
|
|
113
|
+
password,
|
|
114
|
+
changed,
|
|
115
|
+
source: "file",
|
|
116
|
+
file,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pesan gagal login yang bisa ditindaklanjuti.
|
|
122
|
+
*
|
|
123
|
+
* Dua hal yang membedakannya dari pesan lama ("check FACTORY_EMAIL and
|
|
124
|
+
* FACTORY_PASSWORD"): ia menyebut KAPAN proses ini hidup, dan ia menyebut
|
|
125
|
+
* apakah kredensial di berkas berbeda dari yang sedang dipakai. Tanpa itu,
|
|
126
|
+
* pengguna disuruh memeriksa password yang sebenarnya sudah benar.
|
|
127
|
+
*/
|
|
128
|
+
export function describeLoginFailure({ status, processStartedAt, credentialsChanged = false }) {
|
|
129
|
+
const started = processStartedAt instanceof Date ? processStartedAt.toISOString() : "";
|
|
130
|
+
|
|
131
|
+
const lines = [
|
|
132
|
+
`Factory login failed (HTTP ${status}).`,
|
|
133
|
+
started
|
|
134
|
+
? `Proses MCP ini hidup sejak ${started} dan memakai environment yang diwarisi saat itu - ia tidak pernah memuat ulang berkas konfigurasi.`
|
|
135
|
+
: "Proses MCP ini memakai environment yang diwarisi saat ia dijalankan.",
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
if (credentialsChanged) {
|
|
139
|
+
lines.push(
|
|
140
|
+
"Kredensial di berkas konfigurasi BERBEDA dari yang sedang dipakai proses ini. " +
|
|
141
|
+
"Kredensial terbaru sudah dicoba dan tetap ditolak, jadi kemungkinan besar " +
|
|
142
|
+
"password-nya memang salah, atau akunnya belum bisa login."
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
lines.push(
|
|
146
|
+
"Kredensial di berkas konfigurasi SAMA dengan yang dipakai, atau berkasnya tidak ditemukan."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lines.push(
|
|
151
|
+
"Periksa FACTORY_EMAIL/FACTORY_PASSWORD, atau set FACTORY_TOKEN. " +
|
|
152
|
+
"Bila password baru saja diubah, restart OpenCode agar proses MCP memakai kredensial terbaru."
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return lines.join(" ");
|
|
156
|
+
}
|
package/git-facade.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
getRemoteBranchSha,
|
|
17
17
|
getStagedChanges,
|
|
18
18
|
getStatusOutput,
|
|
19
|
+
getToplevel,
|
|
19
20
|
hasStagedChanges,
|
|
20
21
|
hasTestChanges,
|
|
21
22
|
hasWorkingChanges,
|
|
@@ -34,6 +35,7 @@ export function createGitFacade() {
|
|
|
34
35
|
getRemoteBranchSha,
|
|
35
36
|
getLastCommitMessage,
|
|
36
37
|
getStatusOutput,
|
|
38
|
+
getToplevel,
|
|
37
39
|
workingTreeSummary,
|
|
38
40
|
};
|
|
39
41
|
}
|
package/git-helpers.js
CHANGED
|
@@ -173,6 +173,18 @@ export async function getStatusOutput() {
|
|
|
173
173
|
return stdout;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Direktori teratas repo tempat git BENAR-BENAR berjalan.
|
|
178
|
+
*
|
|
179
|
+
* `git rev-parse --show-toplevel` menjawab dari repo yang aktif, bukan dari
|
|
180
|
+
* tebakan kita tentang direktori mana yang dimaksud. Dipakai untuk memastikan
|
|
181
|
+
* laporan `commit_and_push` menyebut repo yang sama dengan yang di-commit.
|
|
182
|
+
*/
|
|
183
|
+
export async function getToplevel() {
|
|
184
|
+
const { stdout } = await exec("git rev-parse --show-toplevel");
|
|
185
|
+
return stdout.trim();
|
|
186
|
+
}
|
|
187
|
+
|
|
176
188
|
export async function getPrimaryBranch() {
|
|
177
189
|
try {
|
|
178
190
|
// Check if 'main' exists on origin
|
package/index.js
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
GetPromptRequestSchema,
|
|
14
14
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
15
15
|
import { fileURLToPath } from "url";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import { acquireProcessLock, releaseProcessLock, lockWarning } from "./process-lock.js";
|
|
16
18
|
import { exec, setActiveProjectDir } from "./exec.js";
|
|
17
19
|
import { normalizeRequestArgs, shellEscape } from "./utils.js";
|
|
18
20
|
import { createGitFacade } from "./git-facade.js";
|
|
@@ -197,6 +199,20 @@ export async function main() {
|
|
|
197
199
|
// Select request metadata before the first disk write. Creating an idle
|
|
198
200
|
// PID fallback here leaves a phantom session alongside the real sessions.
|
|
199
201
|
safeLog('error', `[${new Date().toISOString()}] Workflow state initialization complete.`);
|
|
202
|
+
|
|
203
|
+
// Penanda proses: 31 Oktober 2026 dua proses MCP hidup bersamaan dan
|
|
204
|
+
// memegang kredensial Factory yang berbeda, sehingga tool yang sama
|
|
205
|
+
// menjawab berbeda tergantung proses mana yang melayaninya. Proses lain
|
|
206
|
+
// TIDAK dibunuh (itu berbahaya); cukup dilaporkan supaya pengguna tahu
|
|
207
|
+
// harus menutup OpenCode dan membukanya lagi.
|
|
208
|
+
try {
|
|
209
|
+
const stateDir = path.dirname(path.resolve(workflowState.stateFile));
|
|
210
|
+
const warning = lockWarning(acquireProcessLock({ dir: stateDir }));
|
|
211
|
+
if (warning) safeLog('error', warning);
|
|
212
|
+
process.once('exit', () => releaseProcessLock({ dir: stateDir }));
|
|
213
|
+
} catch (lockError) {
|
|
214
|
+
safeLog('error', 'Warning: could not record process lock:', lockError.message);
|
|
215
|
+
}
|
|
200
216
|
} catch (error) {
|
|
201
217
|
safeLog('error', `[${new Date().toISOString()}] Warning: Failed to initialize workflow state:`, error.message);
|
|
202
218
|
// Continue anyway - workflow state is optional for MCP to function
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@programinglive/dev-workflow-mcp-server",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.14",
|
|
4
4
|
"description": "MCP server to enforce development workflow discipline",
|
|
5
5
|
"homepage": "https://devworkflow.programinglive.com/",
|
|
6
6
|
"mcpName": "io.github.programinglive/dev-workflow-mcp-server",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"start": "node index.js",
|
|
22
22
|
"local": "node index.js",
|
|
23
23
|
"setup": "node scripts/setup-stack.js",
|
|
24
|
-
"test": "node --no-deprecation --test",
|
|
24
|
+
"test": "node --no-deprecation --import ./tests/setup-env.js --test",
|
|
25
25
|
"test:security": "node --no-deprecation --test tests/security*.test.js",
|
|
26
26
|
"security:audit": "node index.js call security_audit",
|
|
27
27
|
"lint": "node scripts/lint.mjs",
|
package/process-lock.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Satu penanda "proses MCP mana yang sedang hidup" per direktori state.
|
|
3
|
+
*
|
|
4
|
+
* Kenapa perlu: 31 Oktober 2026 ada DUA proses dev-workflow MCP hidup
|
|
5
|
+
* bersamaan. Yang lebih tua memegang password Factory lama di memorinya, yang
|
|
6
|
+
* lebih muda memegang yang baru, jadi tool yang sama menjawab berbeda
|
|
7
|
+
* tergantung proses mana yang melayaninya - dan keduanya menulis berkas state
|
|
8
|
+
* workflow yang sama.
|
|
9
|
+
*
|
|
10
|
+
* Modul ini TIDAK membunuh proses lain. Membunuh proses secara otomatis adalah
|
|
11
|
+
* tindakan berbahaya (ada guard tersendiri untuk itu). Yang dilakukan: proses
|
|
12
|
+
* kedua mengetahui bahwa ia bukan satu-satunya, lalu melaporkannya, sehingga
|
|
13
|
+
* pengguna bisa menutup OpenCode dan memulai ulang dengan bersih.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
17
|
+
import os from "os";
|
|
18
|
+
import path from "path";
|
|
19
|
+
|
|
20
|
+
export const LOCK_FILENAME = "mcp-process.lock";
|
|
21
|
+
|
|
22
|
+
/** Lokasi berkas lock untuk satu direktori state. */
|
|
23
|
+
export function processLockPath({ dir }) {
|
|
24
|
+
return path.join(dir, LOCK_FILENAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Membaca isi lock, atau null bila tidak ada / tidak terbaca.
|
|
29
|
+
*
|
|
30
|
+
* Berkas rusak sengaja dianggap "tidak ada lock": penanda yang tidak bisa
|
|
31
|
+
* dibaca tidak boleh membuat server gagal start. Lebih baik dua proses
|
|
32
|
+
* bertabrakan daripada server tidak hidup sama sekali.
|
|
33
|
+
*/
|
|
34
|
+
export function readProcessLock({ dir }) {
|
|
35
|
+
const file = processLockPath({ dir });
|
|
36
|
+
|
|
37
|
+
if (!existsSync(file)) return null;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
41
|
+
if (!parsed || typeof parsed.pid !== "number") return null;
|
|
42
|
+
return parsed;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultIsAlive(pid) {
|
|
49
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Sinyal 0 tidak mengirim apa pun; ia hanya memeriksa apakah proses ada.
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// EPERM berarti proses ADA tetapi milik pengguna lain.
|
|
57
|
+
return error && error.code === "EPERM";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mencoba menjadi pemegang lock.
|
|
63
|
+
*
|
|
64
|
+
* Mengembalikan:
|
|
65
|
+
* { acquired: true } -> kita pemegangnya
|
|
66
|
+
* { acquired: true, tookOverFrom: <pid> } -> pemegang lama sudah mati
|
|
67
|
+
* { acquired: false, holder: {...} } -> ada proses lain yang hidup
|
|
68
|
+
*/
|
|
69
|
+
export function acquireProcessLock({ dir, pid = process.pid, now = Date.now(), isAlive = defaultIsAlive }) {
|
|
70
|
+
const existing = readProcessLock({ dir });
|
|
71
|
+
|
|
72
|
+
if (existing && existing.pid !== pid && isAlive(existing.pid)) {
|
|
73
|
+
return { acquired: false, holder: existing };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tookOverFrom = existing && existing.pid !== pid ? existing.pid : undefined;
|
|
77
|
+
|
|
78
|
+
const payload = { pid, startedAt: now, host: safeHostname() };
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
mkdirSync(dir, { recursive: true });
|
|
82
|
+
writeFileSync(processLockPath({ dir }), JSON.stringify(payload), "utf8");
|
|
83
|
+
} catch {
|
|
84
|
+
// Tidak bisa menulis penanda bukan alasan mematikan server.
|
|
85
|
+
return { acquired: true, wrote: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return tookOverFrom ? { acquired: true, tookOverFrom } : { acquired: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Melepas lock hanya bila memang milik kita. */
|
|
92
|
+
export function releaseProcessLock({ dir, pid = process.pid }) {
|
|
93
|
+
const existing = readProcessLock({ dir });
|
|
94
|
+
|
|
95
|
+
if (!existing || existing.pid !== pid) return false;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
unlinkSync(processLockPath({ dir }));
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function safeHostname() {
|
|
106
|
+
try {
|
|
107
|
+
return os.hostname();
|
|
108
|
+
} catch {
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pesan yang dicatat saat proses lain sudah memegang lock, atau null bila tidak
|
|
115
|
+
* ada yang perlu dilaporkan.
|
|
116
|
+
*
|
|
117
|
+
* Dipisah dari `acquireProcessLock` supaya bisa diuji tanpa menyentuh proses
|
|
118
|
+
* nyata, dan supaya pemanggil bebas memilih tidak menampilkan apa pun.
|
|
119
|
+
*/
|
|
120
|
+
export function lockWarning(result) {
|
|
121
|
+
if (!result || result.acquired !== false || !result.holder) return null;
|
|
122
|
+
|
|
123
|
+
const started = Number.isFinite(result.holder.startedAt)
|
|
124
|
+
? new Date(result.holder.startedAt).toISOString()
|
|
125
|
+
: "waktu tidak diketahui";
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
`Proses dev-workflow MCP lain (PID ${result.holder.pid}, hidup sejak ${started}) ` +
|
|
129
|
+
"masih berjalan dan memakai direktori state yang sama. " +
|
|
130
|
+
"Proses yang lebih tua memegang environment saat ia dijalankan, jadi kredensial Factory " +
|
|
131
|
+
"dan state workflow bisa berbeda antar proses. Tutup OpenCode lalu buka lagi untuk " +
|
|
132
|
+
"memastikan hanya satu proses yang hidup."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -2,6 +2,7 @@ import { textResponse, isReleaseSatisfied, formatChecklist } from "./shared.js";
|
|
|
2
2
|
import { handleReadyCheck } from "./status-handlers.js";
|
|
3
3
|
import { containsTestFilesInStatus } from "../../git-helpers.js";
|
|
4
4
|
import { resolveProjectDir, setActiveProjectDir, getActiveProjectDir } from "../../exec.js";
|
|
5
|
+
import path from "path";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Label repo yang benar-benar dipakai, untuk ditampilkan di setiap jawaban.
|
|
@@ -24,6 +25,18 @@ function repoLabel() {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Apakah dua path menunjuk direktori yang sama.
|
|
30
|
+
*
|
|
31
|
+
* Windows tidak membedakan huruf besar/kecil dan kadang memberi garis miring
|
|
32
|
+
* di akhir, jadi perbandingan string mentah akan salah.
|
|
33
|
+
*/
|
|
34
|
+
function samePath(a, b) {
|
|
35
|
+
if (!a || !b) return false;
|
|
36
|
+
const normal = (value) => path.resolve(value).replace(/[\\/]+$/, "").toLowerCase();
|
|
37
|
+
return normal(a) === normal(b);
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
/**
|
|
28
41
|
* Terapkan `projectDir` dari argumen tool bila ada.
|
|
29
42
|
* Mengembalikan pesan kesalahan, atau null bila tidak ada masalah.
|
|
@@ -127,10 +140,21 @@ export async function handleCommitAndPush(args, context) {
|
|
|
127
140
|
let primaryBranch;
|
|
128
141
|
let currentBranch;
|
|
129
142
|
let hasWorkingChanges;
|
|
143
|
+
let gitToplevel = "";
|
|
130
144
|
try {
|
|
131
145
|
primaryBranch = await git.getPrimaryBranch();
|
|
132
146
|
currentBranch = await git.getCurrentBranch();
|
|
133
147
|
hasWorkingChanges = await git.hasWorkingChanges();
|
|
148
|
+
|
|
149
|
+
// Tempat git BENAR-BENAR berjalan, menurut git sendiri.
|
|
150
|
+
//
|
|
151
|
+
// 31 Oktober 2026 laporan menyebut `portal-beautyworld` untuk commit yang
|
|
152
|
+
// masuk ke repo lain, karena labelnya dibaca dari variabel global yang bisa
|
|
153
|
+
// berubah - bukan dari repo tempat git berjalan. Laporan seperti itu membuat
|
|
154
|
+
// agen percaya commit-nya ada di tempat yang salah.
|
|
155
|
+
if (typeof git.getToplevel === "function") {
|
|
156
|
+
gitToplevel = String((await git.getToplevel()) || "").trim();
|
|
157
|
+
}
|
|
134
158
|
} catch (error) {
|
|
135
159
|
return textResponse(
|
|
136
160
|
`❌ Tidak bisa memeriksa repository Git, jadi tidak ada yang di-commit.\n\n` +
|
|
@@ -140,6 +164,22 @@ export async function handleCommitAndPush(args, context) {
|
|
|
140
164
|
);
|
|
141
165
|
}
|
|
142
166
|
|
|
167
|
+
// Kalau git berjalan di repo lain daripada yang diminta, BERHENTI. Commit ke
|
|
168
|
+
// repo yang salah jauh lebih mahal daripada satu pesan kesalahan.
|
|
169
|
+
const requestedRepo = getActiveProjectDir();
|
|
170
|
+
if (gitToplevel && requestedRepo && !samePath(gitToplevel, requestedRepo)) {
|
|
171
|
+
return textResponse(
|
|
172
|
+
`❌ Dibatalkan: git berjalan di repo yang BERBEDA dari yang diminta.\n\n` +
|
|
173
|
+
`Diminta : ${requestedRepo}\n` +
|
|
174
|
+
`Dipakai git: ${gitToplevel}\n\n` +
|
|
175
|
+
`Tidak ada yang di-commit. Sebutkan 'projectDir' yang benar pada 'commit_and_push', ` +
|
|
176
|
+
`atau jalankan ulang 'start_task' dengan 'projectDir' repo tersebut.`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Label laporan memakai repo yang benar-benar dipakai git bila diketahui.
|
|
181
|
+
const repoName = gitToplevel || repoLabel();
|
|
182
|
+
|
|
143
183
|
const branchForPush = pickPushBranch({ requestedBranch, currentBranch, primaryBranch });
|
|
144
184
|
|
|
145
185
|
if (!hasWorkingChanges) {
|
|
@@ -153,7 +193,7 @@ export async function handleCommitAndPush(args, context) {
|
|
|
153
193
|
if (!pushed.ok) {
|
|
154
194
|
return textResponse(
|
|
155
195
|
`⚠️ Tidak ada perubahan untuk di-commit, tetapi HEAD BELUM ada di remote.\n` +
|
|
156
|
-
`Repo: ${
|
|
196
|
+
`Repo: ${repoName}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
|
|
157
197
|
`Sebab: ${pushed.detail}\n\n` +
|
|
158
198
|
`Jalankan 'commit_and_push' lagi dengan 'branch' yang benar, atau push manual.`
|
|
159
199
|
);
|
|
@@ -166,7 +206,7 @@ export async function handleCommitAndPush(args, context) {
|
|
|
166
206
|
await workflowState.save();
|
|
167
207
|
|
|
168
208
|
return textResponse(
|
|
169
|
-
`ℹ️ No changes detected. Assuming commit and push already completed.\nRepo: ${
|
|
209
|
+
`ℹ️ No changes detected. Assuming commit and push already completed.\nRepo: ${repoName}\nBranch: ${branchForPush} (verified on remote)\nLast commit: ${effectiveCommitMessage || "(unavailable)"
|
|
170
210
|
}\n\nNext: Run 'perform_release' to handle the release and push tags.`
|
|
171
211
|
);
|
|
172
212
|
}
|
|
@@ -228,7 +268,7 @@ export async function handleCommitAndPush(args, context) {
|
|
|
228
268
|
if (!pushed.ok) {
|
|
229
269
|
return textResponse(
|
|
230
270
|
`❌ Commit dibuat, tetapi TIDAK ter-push.\n` +
|
|
231
|
-
`Repo: ${
|
|
271
|
+
`Repo: ${repoName}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
|
|
232
272
|
`Sebab: ${pushed.detail}\n\n` +
|
|
233
273
|
`Commit lokal: ${generatedSummary}\n\n` +
|
|
234
274
|
`Jalankan 'commit_and_push' lagi dengan 'branch' yang benar, atau push manual.`
|
|
@@ -242,7 +282,7 @@ export async function handleCommitAndPush(args, context) {
|
|
|
242
282
|
await workflowState.save();
|
|
243
283
|
|
|
244
284
|
return textResponse(
|
|
245
|
-
`✅ Commit and push completed!\nRepo: ${
|
|
285
|
+
`✅ Commit and push completed!\nRepo: ${repoName}\n\nCommit message: ${generatedSummary}\nPushed to: ${branchForPush} (verified at ${pushed.head.slice(0, 8)})\n\nNext: Run 'perform_release' to handle the release and push tags.`
|
|
246
286
|
);
|
|
247
287
|
}
|
|
248
288
|
|
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
import { textResponse, isReleaseSatisfied, resetToCommitIfWorkingChanges, summarizeHistory } from "./shared.js";
|
|
2
2
|
import { resolveProjectDir, setActiveProjectDir, getActiveProjectDir } from "../../exec.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Label repo yang dipakai, untuk ditampilkan di laporan.
|
|
6
|
+
*
|
|
7
|
+
* Sama seperti di commit-handlers: laporan harus menyebut repo yang benar-benar
|
|
8
|
+
* dipakai, bukan tebakan.
|
|
9
|
+
*/
|
|
10
|
+
function repoLabel() {
|
|
11
|
+
try {
|
|
12
|
+
const resolved = resolveProjectDir({
|
|
13
|
+
explicit: getActiveProjectDir(),
|
|
14
|
+
env: process.env,
|
|
15
|
+
candidates: [process.cwd(), process.env.INIT_CWD],
|
|
16
|
+
});
|
|
17
|
+
const origin = resolved.source === "env" ? " — dari WORKFLOW_PROJECT_DIR (global)" : "";
|
|
18
|
+
return resolved.dir + origin;
|
|
19
|
+
} catch {
|
|
20
|
+
return "(tidak bisa ditentukan)";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Menjalankan perintah git yang hasilnya hanya dipakai untuk laporan.
|
|
26
|
+
*
|
|
27
|
+
* Kegagalan di sini tidak boleh menggagalkan rilis: kalau `git describe`
|
|
28
|
+
* tidak menemukan tag, itu bukan alasan untuk berhenti. Yang penting adalah
|
|
29
|
+
* rilis tetap berjalan dan laporannya jujur tentang apa yang tidak diketahui.
|
|
30
|
+
*/
|
|
31
|
+
async function safeExecStdout(exec, command) {
|
|
32
|
+
try {
|
|
33
|
+
const { stdout } = await exec(command);
|
|
34
|
+
return String(stdout || "").trim();
|
|
35
|
+
} catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
4
40
|
export async function handleSkipRelease(args, workflowState) {
|
|
5
41
|
if (workflowState.state.currentPhase === "idle") {
|
|
6
42
|
return textResponse("⚠️ Please start a task first using 'start_task' before skipping release.");
|
|
@@ -185,11 +221,45 @@ export async function handlePerformRelease(args, context) {
|
|
|
185
221
|
);
|
|
186
222
|
}
|
|
187
223
|
|
|
224
|
+
// Tipe rilis WAJIB sah sebelum perintah disusun.
|
|
225
|
+
//
|
|
226
|
+
// 31 Oktober 2026: `determineReleaseTypeFromCommit` mengembalikan string kosong,
|
|
227
|
+
// lalu perintahnya menjadi `npm run release -- --release-as ` + "null" ->
|
|
228
|
+
// standard-version menulis `"version": null` dan membuat tag `vnull`, lalu
|
|
229
|
+
// keduanya di-push. Yang salah bukan hanya hasilnya: perintah yang mustahil
|
|
230
|
+
// itu tetap dijalankan tanpa perlawanan.
|
|
231
|
+
if (!recognizedTypes.has(releaseType)) {
|
|
232
|
+
return textResponse(
|
|
233
|
+
`⚠️ Tipe rilis tidak bisa ditentukan dari commit terakhir, jadi rilis TIDAK dijalankan.\n\n` +
|
|
234
|
+
`Commit terakhir: ${lastCommitMessage || "(kosong)"}\n\n` +
|
|
235
|
+
`Sebutkan tipe rilisnya secara eksplisit:\n` +
|
|
236
|
+
` • 'perform_release' dengan releaseType: "patch" | "minor" | "major"\n` +
|
|
237
|
+
` • atau command: "npm run release:patch"\n\n` +
|
|
238
|
+
`Tanpa itu perintahnya akan menjadi "--release-as null", yang menghasilkan tag vnull ` +
|
|
239
|
+
`dan package.json berversi null.`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
188
243
|
const finalCommand =
|
|
189
244
|
releaseCommand.includes("--release-as") || /release:(major|minor|patch)/i.test(releaseCommand)
|
|
190
245
|
? releaseCommand
|
|
191
246
|
: `${releaseCommand} -- --release-as ${releaseType}`;
|
|
192
247
|
|
|
248
|
+
// Jaring pengaman terakhir: apa pun yang terjadi di atas, jangan pernah
|
|
249
|
+
// menjalankan perintah yang membawa tipe rilis kosong.
|
|
250
|
+
if (/--release-as\s+(?:null|undefined|)\s*$/i.test(finalCommand) || /\snull\b/i.test(finalCommand)) {
|
|
251
|
+
return textResponse(
|
|
252
|
+
`⚠️ Perintah rilis akan berisi tipe rilis yang tidak sah, jadi TIDAK dijalankan:\n\n` +
|
|
253
|
+
` ${finalCommand}\n\n` +
|
|
254
|
+
`Sebutkan tipe rilisnya: releaseType "patch" | "minor" | "major".`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Catat keadaan SEBELUM rilis supaya, kalau push gagal, kita bisa menyebut
|
|
259
|
+
// persis apa yang tertinggal di mesin.
|
|
260
|
+
const headBefore = await safeExecStdout(exec, "git rev-parse HEAD");
|
|
261
|
+
const tagBefore = await safeExecStdout(exec, "git describe --tags --abbrev=0");
|
|
262
|
+
|
|
193
263
|
try {
|
|
194
264
|
await exec(finalCommand);
|
|
195
265
|
} catch (error) {
|
|
@@ -198,6 +268,9 @@ export async function handlePerformRelease(args, context) {
|
|
|
198
268
|
);
|
|
199
269
|
}
|
|
200
270
|
|
|
271
|
+
const headAfter = await safeExecStdout(exec, "git rev-parse HEAD");
|
|
272
|
+
const tagAfter = await safeExecStdout(exec, "git describe --tags --abbrev=0");
|
|
273
|
+
|
|
201
274
|
const branchForPush = workflowState.state.lastPushBranch || (await git.getCurrentBranch()) || "";
|
|
202
275
|
const pushCommand = branchForPush
|
|
203
276
|
? `git push --follow-tags origin ${utils.shellEscape(branchForPush)}`
|
|
@@ -206,8 +279,29 @@ export async function handlePerformRelease(args, context) {
|
|
|
206
279
|
try {
|
|
207
280
|
await exec(pushCommand);
|
|
208
281
|
} catch (error) {
|
|
282
|
+
// Rilis SUDAH membuat commit dan tag di mesin; hanya push yang gagal.
|
|
283
|
+
// Tanpa menyebut yang tertinggal, tidak jelas apa yang harus dibereskan -
|
|
284
|
+
// dan pada 31 Oktober 2026 hal itu membuat rilis tampak selesai.
|
|
285
|
+
const leftBehind = [];
|
|
286
|
+
if (headAfter && headAfter !== headBefore) {
|
|
287
|
+
leftBehind.push(` • commit rilis ${headAfter.slice(0, 8)}`);
|
|
288
|
+
}
|
|
289
|
+
if (tagAfter && tagAfter !== tagBefore) {
|
|
290
|
+
leftBehind.push(` • tag ${tagAfter}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
209
293
|
return textResponse(
|
|
210
|
-
`❌
|
|
294
|
+
`❌ Push rilis GAGAL. Rilis TIDAK tercatat sebagai selesai.\n\n` +
|
|
295
|
+
`Repo: ${repoLabel()}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
|
|
296
|
+
`Penyebab: ${error.stderr || error.stdout || error.message}\n\n` +
|
|
297
|
+
(leftBehind.length > 0
|
|
298
|
+
? `Tertinggal di mesin (belum sampai ke remote):\n${leftBehind.join("\n")}\n\n`
|
|
299
|
+
: "") +
|
|
300
|
+
`Pemulihan:\n` +
|
|
301
|
+
` 1. periksa: git log --oneline -3 && git tag --points-at HEAD\n` +
|
|
302
|
+
` 2. kirim ulang: git push --follow-tags origin ${branchForPush || "<branch>"}\n` +
|
|
303
|
+
` 3. atau batalkan rilis lokal: git reset --hard HEAD~1 && git tag -d ${tagAfter || "<tag>"}\n\n` +
|
|
304
|
+
`Jangan tandai task selesai sebelum tag benar-benar ada di remote.`
|
|
211
305
|
);
|
|
212
306
|
}
|
|
213
307
|
|
package/workflow-state.js
CHANGED
|
@@ -400,6 +400,19 @@ async function pathExists(filePath) {
|
|
|
400
400
|
}
|
|
401
401
|
|
|
402
402
|
async function ensureCompatibilityLinks(primaryPath) {
|
|
403
|
+
// Cermin kompatibilitas ditulis ke `<paket>/.state/workflow-state.json`,
|
|
404
|
+
// BUKAN ke lokasi state yang sebenarnya. Itu benar untuk pemakaian nyata
|
|
405
|
+
// (CLI dan dasbor web membaca jalur lama), tetapi merusak saat tes: setiap
|
|
406
|
+
// WorkflowState yang dibuat tes - meski di direktori sementara - menimpa
|
|
407
|
+
// state repo, dan 31 Oktober 2026 itu menghapus task yang sedang berjalan di
|
|
408
|
+
// tengah `npm test`.
|
|
409
|
+
//
|
|
410
|
+
// Tes mematikan cermin ini lewat DEV_WORKFLOW_NO_COMPAT_LINKS=1 (lihat
|
|
411
|
+
// tests/setup-env.js). Perilaku produksi tidak berubah.
|
|
412
|
+
if (process.env.DEV_WORKFLOW_NO_COMPAT_LINKS === "1") {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
403
416
|
const resolvedPrimary = path.resolve(primaryPath);
|
|
404
417
|
|
|
405
418
|
if (!(await pathExists(resolvedPrimary))) {
|