triflux 10.35.0 → 10.35.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/bin/tfx-live.mjs +461 -30
- package/bin/triflux.mjs +16 -46
- package/hooks/claude-cwd-projection-refresh.mjs +2 -2
- package/hub/server.mjs +2 -2
- package/package.json +1 -1
- package/scripts/setup.mjs +15 -3
- package/skills/tfx-ship/SKILL.md +54 -23
package/bin/tfx-live.mjs
CHANGED
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
|
-
import { join as pathJoin } from "node:path";
|
|
5
|
+
import { join as pathJoin, resolve as pathResolve } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
+
import {
|
|
9
|
+
escapePwshSingleQuoted as escapeRemotePwshSingleQuoted,
|
|
10
|
+
probeRemoteEnv as probeRemoteHostEnv,
|
|
11
|
+
shellQuote as remoteShellQuote,
|
|
12
|
+
validateHost as validateRemoteHost,
|
|
13
|
+
} from "../hub/team/remote-session.mjs";
|
|
8
14
|
|
|
9
15
|
const execFileAsync = promisify(execFile);
|
|
10
16
|
|
|
@@ -23,6 +29,7 @@ const BRIDGE_TIMEOUT_BUFFER_MS = 15_000;
|
|
|
23
29
|
// spill the JSON to a temp file and pass --payload-file instead.
|
|
24
30
|
const PAYLOAD_FILE_THRESHOLD = 96 * 1024;
|
|
25
31
|
const VALID_TRANSPORTS = ["tmux", "uds", "auto"];
|
|
32
|
+
const BOOLEAN_FLAGS = new Set(["json"]);
|
|
26
33
|
// uds-fallback diagnostics land here, written async so a failed daemon attach
|
|
27
34
|
// never blocks the tmux fallback path (see writeUdsBugReport / doAskAuto).
|
|
28
35
|
const BUG_REPORT_DIR =
|
|
@@ -65,6 +72,11 @@ function parseCli(argv) {
|
|
|
65
72
|
throw new Error("Empty flag is not valid");
|
|
66
73
|
}
|
|
67
74
|
|
|
75
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
76
|
+
flags[key] = "1";
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
68
80
|
const value = rest[index + 1];
|
|
69
81
|
if (value === undefined) {
|
|
70
82
|
throw new Error(`Missing value for --${key}`);
|
|
@@ -168,6 +180,152 @@ function buildTmuxCommand(remote, tmuxArgs) {
|
|
|
168
180
|
};
|
|
169
181
|
}
|
|
170
182
|
|
|
183
|
+
function timeoutSeconds(timeoutMs) {
|
|
184
|
+
return String(Math.max(1, Math.ceil((timeoutMs ?? 1000) / 1000)));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildRemoteLiveArgv(verb, opts) {
|
|
188
|
+
const args = [
|
|
189
|
+
"tfx-live",
|
|
190
|
+
verb,
|
|
191
|
+
"--cli",
|
|
192
|
+
"claude",
|
|
193
|
+
"--transport",
|
|
194
|
+
opts.transport ?? "uds",
|
|
195
|
+
];
|
|
196
|
+
if (opts.short) args.push("--short", opts.short);
|
|
197
|
+
if (opts.sessionId) args.push("--session-id", opts.sessionId);
|
|
198
|
+
if (opts.session) args.push("--session", opts.session);
|
|
199
|
+
if (opts.configDir) args.push("--config-dir", opts.configDir);
|
|
200
|
+
if (verb === "ask") args.push("--prompt", opts.prompt ?? "");
|
|
201
|
+
args.push("--timeout", timeoutSeconds(opts.timeoutMs));
|
|
202
|
+
if (verb === "ask" && opts.settleMs) {
|
|
203
|
+
args.push("--settle", String(opts.settleMs));
|
|
204
|
+
}
|
|
205
|
+
if (verb === "ask" && opts.pollIntervalMs) {
|
|
206
|
+
args.push("--poll-interval", String(opts.pollIntervalMs));
|
|
207
|
+
}
|
|
208
|
+
return args;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function buildRemoteLiveCommand(host, verb, opts, env = {}) {
|
|
212
|
+
validateRemoteHost(host);
|
|
213
|
+
const argv = buildRemoteLiveArgv(verb, opts);
|
|
214
|
+
if (env.os === "win32") {
|
|
215
|
+
const [commandPath, ...args] = argv;
|
|
216
|
+
const command = [
|
|
217
|
+
`& '${escapeRemotePwshSingleQuoted(commandPath)}'`,
|
|
218
|
+
...args.map((arg) => `'${escapeRemotePwshSingleQuoted(arg)}'`),
|
|
219
|
+
].join(" ");
|
|
220
|
+
// Live-unverified for Windows remotes; apply the same SSH single-argument
|
|
221
|
+
// contract proven on darwin so the remote login shell does not re-split it.
|
|
222
|
+
const remoteCmd = `pwsh -NoProfile -Command ${remoteShellQuote(command)}`;
|
|
223
|
+
return {
|
|
224
|
+
command: "ssh",
|
|
225
|
+
args: [host, remoteCmd],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const shell = env.os === "darwin" && env.shell === "zsh" ? "zsh" : "sh";
|
|
230
|
+
const inner = argv.map(remoteShellQuote).join(" ");
|
|
231
|
+
const remoteCmd = `${shell} -lc ${remoteShellQuote(inner)}`;
|
|
232
|
+
return {
|
|
233
|
+
command: "ssh",
|
|
234
|
+
args: [host, remoteCmd],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function tryParseJsonObject(text) {
|
|
239
|
+
try {
|
|
240
|
+
return JSON.parse(text);
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parseRemoteLiveJson(stdout) {
|
|
247
|
+
const text = String(stdout).trim();
|
|
248
|
+
if (!text) {
|
|
249
|
+
throw new Error("remote tfx-live returned empty output");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const direct = tryParseJsonObject(text);
|
|
253
|
+
if (direct && typeof direct === "object" && !Array.isArray(direct)) {
|
|
254
|
+
return direct;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let parsed = null;
|
|
258
|
+
for (let start = 0; start < text.length; start += 1) {
|
|
259
|
+
if (text[start] !== "{") continue;
|
|
260
|
+
let depth = 0;
|
|
261
|
+
let inString = false;
|
|
262
|
+
let escaped = false;
|
|
263
|
+
for (let end = start; end < text.length; end += 1) {
|
|
264
|
+
const char = text[end];
|
|
265
|
+
if (inString) {
|
|
266
|
+
if (escaped) {
|
|
267
|
+
escaped = false;
|
|
268
|
+
} else if (char === "\\") {
|
|
269
|
+
escaped = true;
|
|
270
|
+
} else if (char === '"') {
|
|
271
|
+
inString = false;
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (char === '"') {
|
|
276
|
+
inString = true;
|
|
277
|
+
} else if (char === "{") {
|
|
278
|
+
depth += 1;
|
|
279
|
+
} else if (char === "}") {
|
|
280
|
+
depth -= 1;
|
|
281
|
+
if (depth === 0) {
|
|
282
|
+
const candidate = tryParseJsonObject(text.slice(start, end + 1));
|
|
283
|
+
if (
|
|
284
|
+
candidate &&
|
|
285
|
+
typeof candidate === "object" &&
|
|
286
|
+
!Array.isArray(candidate)
|
|
287
|
+
) {
|
|
288
|
+
parsed = candidate;
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (parsed) return parsed;
|
|
297
|
+
throw new Error(`remote tfx-live output is not JSON: ${text.slice(0, 200)}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function callRemoteLive(verb, opts, deps = {}) {
|
|
301
|
+
const host = validateRemoteHost(opts.remote);
|
|
302
|
+
const probeRemoteEnv = deps.probeRemoteEnv ?? probeRemoteHostEnv;
|
|
303
|
+
const execRemote = deps.sshExec ?? execFileAsync;
|
|
304
|
+
const env = await probeRemoteEnv(host);
|
|
305
|
+
const plan = buildRemoteLiveCommand(host, verb, opts, env);
|
|
306
|
+
const execOptions = {
|
|
307
|
+
timeout: (opts.timeoutMs ?? DEFAULT_ANSWER_TIMEOUT_MS) + 30_000,
|
|
308
|
+
maxBuffer: MAX_BUFFER,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const { stdout } = await execRemote(plan.command, plan.args, execOptions);
|
|
313
|
+
return parseRemoteLiveJson(stdout);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error?.stdout) {
|
|
316
|
+
try {
|
|
317
|
+
return parseRemoteLiveJson(error.stdout);
|
|
318
|
+
} catch {
|
|
319
|
+
// stdout was not a tfx-live JSON result; fall through.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const stderr = error?.stderr ? String(error.stderr).trim() : "";
|
|
323
|
+
throw new Error(
|
|
324
|
+
`remote tfx-live ${verb} failed: ${stderr || error.message}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
171
329
|
async function runTmux(remote, tmuxArgs, options = {}) {
|
|
172
330
|
const { command, args } = buildTmuxCommand(remote, tmuxArgs);
|
|
173
331
|
try {
|
|
@@ -397,6 +555,144 @@ function extractAssistantResponse(adapter, text, prompt = null) {
|
|
|
397
555
|
return response.join("\n").trim();
|
|
398
556
|
}
|
|
399
557
|
|
|
558
|
+
function normalizeClaudeTaskText(text) {
|
|
559
|
+
return String(text)
|
|
560
|
+
.replace(/[.…]+/g, " ")
|
|
561
|
+
.replace(/\s+/g, " ")
|
|
562
|
+
.trim()
|
|
563
|
+
.toLowerCase();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function isTaskListBoundary(line) {
|
|
567
|
+
const trimmed = line.trim();
|
|
568
|
+
return (
|
|
569
|
+
!trimmed ||
|
|
570
|
+
trimmed === "Working" ||
|
|
571
|
+
trimmed === "Completed" ||
|
|
572
|
+
/^[─-]{8,}$/.test(trimmed) ||
|
|
573
|
+
hasClaudeComposerPrompt(line) ||
|
|
574
|
+
/enter to open|space to reply|ctrl\+x to delete|\? for shortcuts/i.test(
|
|
575
|
+
trimmed,
|
|
576
|
+
)
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function parseClaudeCompletedTaskListEntries(text) {
|
|
581
|
+
const lines = String(text)
|
|
582
|
+
.split("\n")
|
|
583
|
+
.map((line) => line.replace(/\s+$/g, ""));
|
|
584
|
+
const entries = [];
|
|
585
|
+
let inCompleted = false;
|
|
586
|
+
|
|
587
|
+
for (const line of lines) {
|
|
588
|
+
const trimmed = line.trim();
|
|
589
|
+
if (trimmed === "Completed") {
|
|
590
|
+
inCompleted = true;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (!inCompleted) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (isTaskListBoundary(line)) {
|
|
597
|
+
if (trimmed) {
|
|
598
|
+
inCompleted = false;
|
|
599
|
+
}
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (!/^\s*✻\s+/.test(line)) {
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const body = trimmed.replace(/^✻\s+/, "");
|
|
607
|
+
const columns = body
|
|
608
|
+
.split(/\s{2,}/)
|
|
609
|
+
.map((column) => column.trim())
|
|
610
|
+
.filter(Boolean);
|
|
611
|
+
if (columns.length < 2) {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
let responseIndex = columns.length - 1;
|
|
616
|
+
const tail = columns[responseIndex];
|
|
617
|
+
if (/^(?:\d+\s*[smhdw]|now|just now)$/i.test(tail)) {
|
|
618
|
+
responseIndex -= 1;
|
|
619
|
+
}
|
|
620
|
+
if (responseIndex <= 0) {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const summary = columns.slice(0, responseIndex).join(" ").trim();
|
|
625
|
+
const response = columns[responseIndex].trim();
|
|
626
|
+
if (!summary || !response) {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
entries.push({
|
|
630
|
+
summary,
|
|
631
|
+
response,
|
|
632
|
+
key: `${normalizeClaudeTaskText(summary)}\0${normalizeClaudeTaskText(
|
|
633
|
+
response,
|
|
634
|
+
)}`,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return entries;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function claudeTaskMatchesPrompt(entry, prompt) {
|
|
642
|
+
if (!prompt) {
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
const summary = normalizeClaudeTaskText(entry.summary);
|
|
646
|
+
const normalizedPrompt = normalizeClaudeTaskText(prompt);
|
|
647
|
+
return (
|
|
648
|
+
Boolean(summary && normalizedPrompt) &&
|
|
649
|
+
(summary.includes(normalizedPrompt) || normalizedPrompt.includes(summary))
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function extractClaudeCompletedTaskListResponse(text, options = {}) {
|
|
654
|
+
const beforeEntries = parseClaudeCompletedTaskListEntries(options.beforeText);
|
|
655
|
+
const beforeKeys = new Set(beforeEntries.map((entry) => entry.key));
|
|
656
|
+
const newEntries = parseClaudeCompletedTaskListEntries(text).filter(
|
|
657
|
+
(entry) => !beforeKeys.has(entry.key),
|
|
658
|
+
);
|
|
659
|
+
if (newEntries.length === 0) {
|
|
660
|
+
return "";
|
|
661
|
+
}
|
|
662
|
+
const promptMatches = options.prompt
|
|
663
|
+
? newEntries.filter((entry) =>
|
|
664
|
+
claudeTaskMatchesPrompt(entry, options.prompt),
|
|
665
|
+
)
|
|
666
|
+
: [];
|
|
667
|
+
if (promptMatches.length > 0) {
|
|
668
|
+
return promptMatches[0].response;
|
|
669
|
+
}
|
|
670
|
+
if (newEntries.length === 1) {
|
|
671
|
+
return newEntries[0].response;
|
|
672
|
+
}
|
|
673
|
+
return newEntries.at(-1).response;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function hasClaudeCompletedTaskListResponse(text, options = {}) {
|
|
677
|
+
return extractClaudeCompletedTaskListResponse(text, options).length > 0;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function hasTmuxAssistantResponseAfterPrompt(
|
|
681
|
+
adapter,
|
|
682
|
+
text,
|
|
683
|
+
prompt,
|
|
684
|
+
beforeText,
|
|
685
|
+
) {
|
|
686
|
+
return (
|
|
687
|
+
hasAssistantResponseAfterPrompt(adapter, text, prompt) ||
|
|
688
|
+
(adapter.cli === "claude" &&
|
|
689
|
+
hasClaudeCompletedTaskListResponse(text, {
|
|
690
|
+
beforeText,
|
|
691
|
+
prompt,
|
|
692
|
+
}))
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
400
696
|
async function capturePane(remote, session) {
|
|
401
697
|
const { stdout } = await runTmux(remote, [
|
|
402
698
|
"capture-pane",
|
|
@@ -805,6 +1101,63 @@ async function probeDaemon(bridgePath, ref, timeoutMs) {
|
|
|
805
1101
|
}
|
|
806
1102
|
}
|
|
807
1103
|
|
|
1104
|
+
async function hasTmuxSession(adapter, opts) {
|
|
1105
|
+
if (!opts.session) return false;
|
|
1106
|
+
try {
|
|
1107
|
+
await runTmux(opts.remote, ["has-session", "-t", opts.session]);
|
|
1108
|
+
return true;
|
|
1109
|
+
} catch {
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function daemonProbeUnavailableReason(probe, targetAttachable) {
|
|
1115
|
+
if (targetAttachable) return null;
|
|
1116
|
+
if (!probe?.ok) return probe?.reason ?? "probe-failed";
|
|
1117
|
+
return "target-not-found";
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
async function resolveAskTransport(adapter, opts, deps = {}) {
|
|
1121
|
+
const transport = opts.transport ?? "tmux";
|
|
1122
|
+
if (transport !== "auto") {
|
|
1123
|
+
return {
|
|
1124
|
+
transport,
|
|
1125
|
+
transportSelected: transport,
|
|
1126
|
+
transportProbe: null,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const tmuxProbe = deps.hasTmuxSession ?? hasTmuxSession;
|
|
1131
|
+
const daemonProbe = deps.probeDaemon ?? probeDaemon;
|
|
1132
|
+
const [tmux, probe] = await Promise.all([
|
|
1133
|
+
tmuxProbe(adapter, opts),
|
|
1134
|
+
daemonProbe(
|
|
1135
|
+
opts.bridgePath,
|
|
1136
|
+
{
|
|
1137
|
+
short: opts.short,
|
|
1138
|
+
sessionId: opts.sessionId,
|
|
1139
|
+
configDir: opts.configDir,
|
|
1140
|
+
},
|
|
1141
|
+
opts.timeoutMs,
|
|
1142
|
+
),
|
|
1143
|
+
]);
|
|
1144
|
+
const daemon = daemonProbeTargetAttachable(probe, opts);
|
|
1145
|
+
const daemonReason = daemonProbeUnavailableReason(probe, daemon);
|
|
1146
|
+
const transportProbe = {
|
|
1147
|
+
tmux: tmux === true,
|
|
1148
|
+
daemon,
|
|
1149
|
+
...(daemonReason ? { daemonReason } : {}),
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1152
|
+
return {
|
|
1153
|
+
transport: "auto",
|
|
1154
|
+
transportSelected: daemon ? "uds" : tmux ? "tmux" : "none",
|
|
1155
|
+
transportProbe,
|
|
1156
|
+
daemonProbe: probe,
|
|
1157
|
+
daemonConfigDir: opts.configDir ?? probe?.raw?.daemon?.configDir ?? null,
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
|
|
808
1161
|
async function doStart(adapter, opts) {
|
|
809
1162
|
const {
|
|
810
1163
|
session,
|
|
@@ -880,7 +1233,7 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
880
1233
|
const doneSignal =
|
|
881
1234
|
!adapter.isBusy(visible) &&
|
|
882
1235
|
ready &&
|
|
883
|
-
|
|
1236
|
+
hasTmuxAssistantResponseAfterPrompt(adapter, visible, prompt, beforeRaw);
|
|
884
1237
|
if (doneSignal && quietPolls >= FALLBACK_QUIET_POLLS) {
|
|
885
1238
|
done = true;
|
|
886
1239
|
break;
|
|
@@ -891,6 +1244,15 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
891
1244
|
|
|
892
1245
|
raw = await capturePane(remote, session);
|
|
893
1246
|
response = extractAssistantResponse(adapter, raw, prompt);
|
|
1247
|
+
if (!response && adapter.cli === "claude") {
|
|
1248
|
+
response = extractClaudeCompletedTaskListResponse(raw, {
|
|
1249
|
+
beforeText: beforeRaw,
|
|
1250
|
+
prompt,
|
|
1251
|
+
});
|
|
1252
|
+
if (response) {
|
|
1253
|
+
done = true;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
894
1256
|
contextPctAfter = adapter.contextPct(raw);
|
|
895
1257
|
|
|
896
1258
|
return addLoginGuard(adapter, raw, {
|
|
@@ -901,6 +1263,7 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
901
1263
|
response,
|
|
902
1264
|
contextPctBefore,
|
|
903
1265
|
contextPctAfter,
|
|
1266
|
+
matchedCompletion: done,
|
|
904
1267
|
done,
|
|
905
1268
|
raw,
|
|
906
1269
|
});
|
|
@@ -924,6 +1287,10 @@ async function doAsk(adapter, opts) {
|
|
|
924
1287
|
);
|
|
925
1288
|
}
|
|
926
1289
|
|
|
1290
|
+
if (opts.remote) {
|
|
1291
|
+
return doAskViaRemoteLive(adapter, opts);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
927
1294
|
if (transport === "uds") {
|
|
928
1295
|
if (!opts.bridgePath) {
|
|
929
1296
|
throw new Error(
|
|
@@ -936,6 +1303,16 @@ async function doAsk(adapter, opts) {
|
|
|
936
1303
|
return doAskAuto(adapter, opts);
|
|
937
1304
|
}
|
|
938
1305
|
|
|
1306
|
+
async function doAskViaRemoteLive(adapter, opts, deps = {}) {
|
|
1307
|
+
const result = await callRemoteLive("ask", opts, deps);
|
|
1308
|
+
return {
|
|
1309
|
+
...result,
|
|
1310
|
+
cli: result.cli ?? adapter.cli,
|
|
1311
|
+
remote: opts.remote,
|
|
1312
|
+
remoteRelay: true,
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
|
|
939
1316
|
async function doAskViaDaemon(opts, meta = {}) {
|
|
940
1317
|
const { bridgePath, prompt, timeoutMs, short, sessionId, configDir } = opts;
|
|
941
1318
|
const payload = { prompt, timeoutMs };
|
|
@@ -1087,23 +1464,26 @@ async function runTmuxFallback(adapter, opts, reason, udsResult) {
|
|
|
1087
1464
|
}
|
|
1088
1465
|
|
|
1089
1466
|
async function doAskAuto(adapter, opts) {
|
|
1090
|
-
const
|
|
1091
|
-
|
|
1092
|
-
{ short: opts.short, sessionId: opts.sessionId, configDir: opts.configDir },
|
|
1093
|
-
opts.timeoutMs,
|
|
1094
|
-
);
|
|
1095
|
-
// daemon-probe returns the full session list (it does not filter by the
|
|
1096
|
-
// requested short), so confirm the target is actually attachable here rather
|
|
1097
|
-
// than firing a doomed attach at a missing short.
|
|
1098
|
-
const targetAttachable = daemonProbeTargetAttachable(probe, opts);
|
|
1467
|
+
const resolution = await resolveAskTransport(adapter, opts);
|
|
1468
|
+
const probe = resolution.daemonProbe;
|
|
1099
1469
|
|
|
1100
|
-
if (
|
|
1101
|
-
const
|
|
1470
|
+
if (resolution.transportSelected === "tmux") {
|
|
1471
|
+
const tmuxResult = await doAskViaTmux(adapter, opts);
|
|
1472
|
+
return {
|
|
1473
|
+
...tmuxResult,
|
|
1474
|
+
transport: "auto",
|
|
1475
|
+
transportSelected: "tmux",
|
|
1476
|
+
transportProbe: resolution.transportProbe,
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
if (resolution.transportSelected === "uds") {
|
|
1481
|
+
const daemonConfigDir = resolution.daemonConfigDir;
|
|
1102
1482
|
const udsResult = await doAskViaDaemon(
|
|
1103
1483
|
{ ...opts, configDir: daemonConfigDir },
|
|
1104
1484
|
{
|
|
1105
1485
|
transportSelected: "uds",
|
|
1106
|
-
transportProbe:
|
|
1486
|
+
transportProbe: resolution.transportProbe,
|
|
1107
1487
|
},
|
|
1108
1488
|
);
|
|
1109
1489
|
if (udsResult.matchedCompletion) {
|
|
@@ -1123,27 +1503,48 @@ async function doAskAuto(adapter, opts) {
|
|
|
1123
1503
|
probe,
|
|
1124
1504
|
attachError,
|
|
1125
1505
|
});
|
|
1126
|
-
const fallback =
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1506
|
+
const fallback =
|
|
1507
|
+
resolution.transportProbe?.tmux === true
|
|
1508
|
+
? await runTmuxFallback(
|
|
1509
|
+
adapter,
|
|
1510
|
+
opts,
|
|
1511
|
+
"uds-attach-incomplete",
|
|
1512
|
+
udsResult,
|
|
1513
|
+
)
|
|
1514
|
+
: {
|
|
1515
|
+
cli: adapter.cli,
|
|
1516
|
+
transport: "auto",
|
|
1517
|
+
transportSelected: "none",
|
|
1518
|
+
transportProbe: resolution.transportProbe,
|
|
1519
|
+
response: "",
|
|
1520
|
+
done: false,
|
|
1521
|
+
error:
|
|
1522
|
+
"uds unavailable (uds-attach-incomplete) and no tmux session for fallback",
|
|
1523
|
+
udsError: udsResult.error ?? attachError,
|
|
1524
|
+
};
|
|
1132
1525
|
await reportPromise.catch(() => {});
|
|
1133
1526
|
return fallback;
|
|
1134
1527
|
}
|
|
1135
1528
|
|
|
1136
1529
|
// uds is unavailable: daemon unreachable, or reachable but target not listed.
|
|
1137
|
-
const reason =
|
|
1138
|
-
? (probe.reason ?? "probe-failed")
|
|
1139
|
-
: "target-not-found";
|
|
1530
|
+
const reason = resolution.transportProbe?.daemonReason ?? "target-not-found";
|
|
1140
1531
|
const reportPromise = writeUdsBugReport(reason, {
|
|
1141
1532
|
short: opts.short,
|
|
1142
1533
|
sessionId: opts.sessionId,
|
|
1143
1534
|
bridgePath: opts.bridgePath,
|
|
1144
1535
|
probe,
|
|
1145
1536
|
});
|
|
1146
|
-
const fallback =
|
|
1537
|
+
const fallback = {
|
|
1538
|
+
cli: adapter.cli,
|
|
1539
|
+
transport: "auto",
|
|
1540
|
+
transportSelected: "none",
|
|
1541
|
+
transportProbe: resolution.transportProbe,
|
|
1542
|
+
response: "",
|
|
1543
|
+
done: false,
|
|
1544
|
+
error: opts.session
|
|
1545
|
+
? `uds unavailable (${reason}) and tmux session unavailable`
|
|
1546
|
+
: `uds unavailable (${reason}) and no --session for tmux fallback`,
|
|
1547
|
+
};
|
|
1147
1548
|
await reportPromise.catch(() => {});
|
|
1148
1549
|
return fallback;
|
|
1149
1550
|
}
|
|
@@ -1206,6 +1607,16 @@ async function doInterruptViaDaemon(opts, meta = {}) {
|
|
|
1206
1607
|
};
|
|
1207
1608
|
}
|
|
1208
1609
|
|
|
1610
|
+
async function doInterruptViaRemoteLive(adapter, opts, deps = {}) {
|
|
1611
|
+
const result = await callRemoteLive("interrupt", opts, deps);
|
|
1612
|
+
return {
|
|
1613
|
+
...result,
|
|
1614
|
+
cli: result.cli ?? adapter.cli,
|
|
1615
|
+
remote: opts.remote,
|
|
1616
|
+
remoteRelay: true,
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1209
1620
|
async function doInterrupt(adapter, opts) {
|
|
1210
1621
|
const transport = opts.transport ?? "tmux";
|
|
1211
1622
|
if (transport === "tmux") {
|
|
@@ -1221,6 +1632,9 @@ async function doInterrupt(adapter, opts) {
|
|
|
1221
1632
|
`--transport ${transport} requires --short or --session-id`,
|
|
1222
1633
|
);
|
|
1223
1634
|
}
|
|
1635
|
+
if (opts.remote) {
|
|
1636
|
+
return doInterruptViaRemoteLive(adapter, opts);
|
|
1637
|
+
}
|
|
1224
1638
|
if (!opts.bridgePath) {
|
|
1225
1639
|
throw new Error(
|
|
1226
1640
|
`--transport ${transport} requires a bridge path (--bridge, TFX_BRIDGE, or TFX_REPO_ROOT)`,
|
|
@@ -1811,10 +2225,27 @@ async function main() {
|
|
|
1811
2225
|
}
|
|
1812
2226
|
}
|
|
1813
2227
|
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
2228
|
+
export {
|
|
2229
|
+
ADAPTERS,
|
|
2230
|
+
buildRemoteLiveCommand,
|
|
2231
|
+
callRemoteLive,
|
|
2232
|
+
extractAssistantResponse,
|
|
2233
|
+
extractClaudeCompletedTaskListResponse,
|
|
2234
|
+
hasClaudeCompletedTaskListResponse,
|
|
2235
|
+
parseRemoteLiveJson,
|
|
2236
|
+
resolveAskTransport,
|
|
2237
|
+
};
|
|
2238
|
+
|
|
2239
|
+
const isDirectRun =
|
|
2240
|
+
process.argv[1] &&
|
|
2241
|
+
fileURLToPath(import.meta.url) === pathResolve(process.argv[1]);
|
|
2242
|
+
|
|
2243
|
+
if (isDirectRun) {
|
|
2244
|
+
main().catch((error) => {
|
|
2245
|
+
printJson({
|
|
2246
|
+
ok: false,
|
|
2247
|
+
error: error.message,
|
|
2248
|
+
});
|
|
2249
|
+
process.exitCode = 1;
|
|
1818
2250
|
});
|
|
1819
|
-
|
|
1820
|
-
});
|
|
2251
|
+
}
|
package/bin/triflux.mjs
CHANGED
|
@@ -3328,72 +3328,42 @@ async function cmdDoctor(options = {}) {
|
|
|
3328
3328
|
}
|
|
3329
3329
|
}
|
|
3330
3330
|
|
|
3331
|
-
// 4.5 Serena MCP
|
|
3331
|
+
// 4.5 Serena MCP — 2026-06-10 core 에서 제거됨([Serena Core Removal]).
|
|
3332
|
+
// serena 부재가 정상 상태이며, 잔존 시 부활로 간주해 제거를 권고한다(should-be-absent).
|
|
3332
3333
|
section("Serena MCP");
|
|
3333
3334
|
if (existsSync(CODEX_CONFIG_PATH)) {
|
|
3334
3335
|
const codexConfig = readFileSync(CODEX_CONFIG_PATH, "utf8");
|
|
3335
3336
|
const serenaConfig = inspectSerenaMcpConfig(codexConfig);
|
|
3336
3337
|
if (!serenaConfig.present) {
|
|
3337
|
-
|
|
3338
|
-
info(
|
|
3339
|
-
"권장: [mcp_servers.serena]에 --project-from-cwd, --context codex, startup_timeout_sec=30+ 설정",
|
|
3340
|
-
);
|
|
3338
|
+
ok("serena 미설정 (정상 — 2026-06-10 core 제거)");
|
|
3341
3339
|
addDoctorCheck(report, {
|
|
3342
3340
|
name: "serena-mcp",
|
|
3343
|
-
status: "
|
|
3341
|
+
status: "ok",
|
|
3344
3342
|
path: CODEX_CONFIG_PATH,
|
|
3345
|
-
|
|
3343
|
+
note: "serena removed from core 2026-06-10; absence is expected.",
|
|
3346
3344
|
});
|
|
3347
|
-
issues++;
|
|
3348
3345
|
} else {
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
if (serenaConfig.hasProjectBinding) ok("project binding: 정상");
|
|
3353
|
-
else {
|
|
3354
|
-
warn("project binding 없음");
|
|
3355
|
-
info("권장: --project-from-cwd 또는 --project <path>");
|
|
3356
|
-
issues++;
|
|
3357
|
-
}
|
|
3358
|
-
|
|
3359
|
-
if (serenaConfig.hasContextCodex) info("context codex: 설정됨");
|
|
3360
|
-
else info("context codex: 미설정");
|
|
3361
|
-
|
|
3362
|
-
if (serenaConfig.startupTimeoutSec === null) {
|
|
3363
|
-
warn("startup_timeout_sec 미설정");
|
|
3364
|
-
info("권장: startup_timeout_sec = 30 이상");
|
|
3365
|
-
issues++;
|
|
3366
|
-
} else if (serenaConfig.timeoutRecommended) {
|
|
3367
|
-
ok(`startup timeout: ${serenaConfig.startupTimeoutSec}s`);
|
|
3368
|
-
} else {
|
|
3369
|
-
warn(`startup timeout 낮음: ${serenaConfig.startupTimeoutSec}s`);
|
|
3370
|
-
info("권장: startup_timeout_sec = 30 이상");
|
|
3371
|
-
issues++;
|
|
3372
|
-
}
|
|
3373
|
-
|
|
3346
|
+
// 제거 결정 이후 serena 가 다시 나타났다 — 부활 감지(should-be-absent 위반).
|
|
3347
|
+
warn("serena MCP 설정 잔존 — core 에서 제거됨(2026-06-10). 부활 감지");
|
|
3348
|
+
info("제거 권장: ~/.codex/config.toml 의 [mcp_servers.serena] 삭제");
|
|
3374
3349
|
addDoctorCheck(report, {
|
|
3375
3350
|
name: "serena-mcp",
|
|
3376
|
-
status:
|
|
3351
|
+
status: "issues",
|
|
3377
3352
|
path: CODEX_CONFIG_PATH,
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
startup_timeout_sec: serenaConfig.startupTimeoutSec,
|
|
3381
|
-
...(hasSerenaIssues
|
|
3382
|
-
? {
|
|
3383
|
-
fix: "Serena MCP에 --project-from-cwd 와 startup_timeout_sec=30+ 를 설정하세요.",
|
|
3384
|
-
}
|
|
3385
|
-
: {}),
|
|
3353
|
+
resurrected: true,
|
|
3354
|
+
fix: "serena 는 core 에서 제거되었습니다. ~/.codex/config.toml 의 [mcp_servers.serena] 항목을 삭제하세요.",
|
|
3386
3355
|
});
|
|
3356
|
+
issues++;
|
|
3387
3357
|
}
|
|
3388
3358
|
} else {
|
|
3359
|
+
// config.toml 미존재 — serena 부재는 정상. 이슈로 집계하지 않는다.
|
|
3360
|
+
ok("config.toml 미존재 — serena 진단 불필요");
|
|
3389
3361
|
addDoctorCheck(report, {
|
|
3390
3362
|
name: "serena-mcp",
|
|
3391
|
-
status: "
|
|
3363
|
+
status: "ok",
|
|
3392
3364
|
path: CODEX_CONFIG_PATH,
|
|
3393
|
-
|
|
3365
|
+
note: "config.toml absent; serena not required.",
|
|
3394
3366
|
});
|
|
3395
|
-
warn("config.toml 미존재 — Serena MCP 진단 건너뜀");
|
|
3396
|
-
issues++;
|
|
3397
3367
|
}
|
|
3398
3368
|
|
|
3399
3369
|
// 5. Antigravity CLI
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -8,7 +8,7 @@ import { refreshClaudeSessionProjectionCwd } from "../hub/team/claude-session-pr
|
|
|
8
8
|
|
|
9
9
|
function readStdin() {
|
|
10
10
|
try {
|
|
11
|
-
return
|
|
11
|
+
return Promise.resolve(readFileSync(0, "utf8"));
|
|
12
12
|
} catch {
|
|
13
13
|
return Promise.resolve("");
|
|
14
14
|
}
|
package/hub/server.mjs
CHANGED
|
@@ -517,7 +517,7 @@ function getRequestPath(url = "/") {
|
|
|
517
517
|
}
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
-
function isLoopbackRemoteAddress(remoteAddress) {
|
|
520
|
+
export function isLoopbackRemoteAddress(remoteAddress) {
|
|
521
521
|
return (
|
|
522
522
|
typeof remoteAddress === "string" &&
|
|
523
523
|
LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress)
|
|
@@ -563,7 +563,7 @@ function safeTokenCompare(a, b) {
|
|
|
563
563
|
return timingSafeEqual(ha, hb);
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
-
function isAuthorizedRequest(req, path, hubToken) {
|
|
566
|
+
export function isAuthorizedRequest(req, path, hubToken) {
|
|
567
567
|
if (!hubToken) {
|
|
568
568
|
return isLoopbackRemoteAddress(req.socket.remoteAddress);
|
|
569
569
|
}
|
package/package.json
CHANGED
package/scripts/setup.mjs
CHANGED
|
@@ -1374,13 +1374,25 @@ function ensureCriticalSetup() {
|
|
|
1374
1374
|
|
|
1375
1375
|
try {
|
|
1376
1376
|
ensureCodexProfiles();
|
|
1377
|
-
} catch {
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
process.stderr.write(
|
|
1379
|
+
`[tfx-setup] ensureCodexProfiles 실패: ${error?.message || error}\n`,
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1378
1382
|
try {
|
|
1379
1383
|
ensureCodexHooks();
|
|
1380
|
-
} catch {
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
process.stderr.write(
|
|
1386
|
+
`[tfx-setup] ensureCodexHooks 실패: ${error?.message || error}\n`,
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1381
1389
|
try {
|
|
1382
1390
|
ensureAgyHooks();
|
|
1383
|
-
} catch {
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
process.stderr.write(
|
|
1393
|
+
`[tfx-setup] ensureAgyHooks 실패: ${error?.message || error}\n`,
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1384
1396
|
}
|
|
1385
1397
|
|
|
1386
1398
|
export {
|
package/skills/tfx-ship/SKILL.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tfx-ship
|
|
3
3
|
description: >
|
|
4
|
-
triflux 전용 릴리즈 자동화.
|
|
5
|
-
|
|
4
|
+
triflux 전용 릴리즈 자동화. **GitHub Actions(release.yml dispatch / npm-publish.yml) 기반 CI 릴리즈가 기본 경로 —
|
|
5
|
+
npm publish 는 OIDC Trusted Publishing 으로 CI 가 수행하므로 로컬 npm login 불필요.** scripts/release/* 래퍼 +
|
|
6
|
+
AskUserQuestion 기반 버전 선택 + CHANGELOG 편집 게이트 + Co-Authored-By/AI trailer 금지 강제. 'ship', '배포', '릴리즈',
|
|
6
7
|
'release', 'tfx-ship', 'publish' 같은 요청에 반드시 사용.
|
|
7
8
|
argument-hint: "[patch|minor|major|<version>] [--skip-tests] [--no-publish] [--dry-run]"
|
|
8
9
|
---
|
|
@@ -27,10 +28,11 @@ triflux 는 아래 3채널로 동시 배포. 각 채널의 버전은 반드시
|
|
|
27
28
|
| # | 채널 | 명령 | 우선순위 |
|
|
28
29
|
|---|------|------|---------|
|
|
29
30
|
| 1 | **GitHub Releases** | `gh release create vX.Y.Z --notes-file <notes>` | 공지 + changelog 공식 소스 |
|
|
30
|
-
| 2 | **npm registry** | `
|
|
31
|
+
| 2 | **npm registry** | **CI `npm-publish.yml`** (`v*` 태그 push 자동 / OIDC Trusted Publishing, `NPM_TOKEN` secret) | primary distribution |
|
|
31
32
|
| 3 | **Claude Code marketplace** | `.claude-plugin/marketplace.json` (`source: npm` 참조) | `claude plugin add triflux` |
|
|
32
33
|
| 4 | **pypi** (future, 비활성) | 현재 `pyproject.toml` 없음 | 활성화 시 Step 10.5 신설 |
|
|
33
34
|
|
|
35
|
+
- **npm publish 는 로컬에서 하지 않는다** — `release.yml` dispatch 또는 `v*` 태그 push 시 `npm-publish.yml` 이 OIDC 로 발행한다. 로컬 `publish.mjs --execute` 는 CI 불가 시 폴백 전용.
|
|
34
36
|
- marketplace 는 자체 publish 명령이 없음. marketplace.json 의 version 만 갱신하면 git push 로 반영됨 (GitHub 호스팅).
|
|
35
37
|
- `release:check-sync` 가 package.json + marketplace.json + package-lock.json 3곳 version 일치를 강제한다.
|
|
36
38
|
- pypi 는 triflux 가 Python 모듈을 가지게 되면 활성화. 현 단계는 플레이스홀더.
|
|
@@ -40,12 +42,47 @@ triflux 는 아래 3채널로 동시 배포. 각 채널의 버전은 반드시
|
|
|
40
42
|
## 전제 조건
|
|
41
43
|
|
|
42
44
|
- `~/.claude/scripts/tfx-route.sh` 불필요 (CLI 워커 호출 없음)
|
|
43
|
-
- `gh` CLI 인증됨 (`gh auth status`)
|
|
44
|
-
- `npm`
|
|
45
|
-
-
|
|
46
|
-
- `origin/main` 과 동기화된 상태 (behind 면 먼저 pull)
|
|
45
|
+
- `gh` CLI 인증됨 (`gh auth status`) — `release.yml` dispatch / GitHub release 에 필요. 깨졌으면 웹 UI 대체
|
|
46
|
+
- **npm 인증 불필요** — npm publish 는 CI(`npm-publish.yml`)가 OIDC/`NPM_TOKEN` 으로 수행 (로컬 `npm login` 필요 없음)
|
|
47
|
+
- 릴리즈할 코드가 `origin/main` 에 있을 것 (CI 는 `--ref main` 으로 origin 을 checkout)
|
|
47
48
|
|
|
48
|
-
##
|
|
49
|
+
## 기본 경로 — CI 릴리즈 (권장)
|
|
50
|
+
|
|
51
|
+
> **npm publish 는 로컬에서 하지 않는다.** GitHub Actions 가 OIDC(Trusted Publishing)로 발행한다.
|
|
52
|
+
> 워크플로우: `.github/workflows/{release,npm-publish,ci}.yml`
|
|
53
|
+
|
|
54
|
+
- **`release.yml`** (`workflow_dispatch`, inputs: `version`, `channel`): 한 번의 dispatch 로 prepare(테스트/버전 bump) → 태그 + GitHub release(`publish.mjs --skip-npm`) → `npm-publish.yml` dispatch → npm publish 완료 대기 → `verify.mjs` 까지 전부 CI(ubuntu, node 24)에서 수행.
|
|
55
|
+
- **`npm-publish.yml`** (`on: push tags ['v*']` + dispatch): `v*` 태그가 push 되면 자동 npm publish (`--provenance --access public`, `NPM_TOKEN` secret). 루트/core/triflux 3패키지 각각, 이미 게시된 버전은 skip.
|
|
56
|
+
|
|
57
|
+
### 권장 실행 — release.yml 디스패치
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
gh workflow run release.yml --ref main -f version=<X.Y.Z> -f channel=stable
|
|
61
|
+
# 진행 관찰
|
|
62
|
+
gh run list --workflow release.yml -L1
|
|
63
|
+
gh run watch "$(gh run list --workflow release.yml -L1 --json databaseId -q '.[0].databaseId')"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- gh 인증이 없는 환경(예: SSH→m2, hosts.yml PAT 만료)이면 **gh 가 정상인 머신(m5)에서** dispatch 하거나 **GitHub 웹 UI**(Actions → release → Run workflow)로 대체.
|
|
67
|
+
- CHANGELOG 가 필요하면 dispatch 전에 main 에 별도 커밋으로 반영(아래 로컬 플로우 Step 4 양식 참조).
|
|
68
|
+
|
|
69
|
+
### 경량 경로 — 태그만 push
|
|
70
|
+
|
|
71
|
+
이미 검증된 hotfix 등 prepare/verify 없이 npm publish 만 트리거하려면:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# main 에서 bump 커밋(버전 3곳 동기화) 후
|
|
75
|
+
git tag v<X.Y.Z> && git push origin v<X.Y.Z> # → npm-publish.yml 자동 발동
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
> ⚠️ 이 경로는 GitHub release / verify 를 건너뛴다. 버전 동기화(package.json + marketplace.json + lock)는 직접 보장.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 로컬 수동 플로우 (폴백 — CI 불가 시에만)
|
|
83
|
+
|
|
84
|
+
> CI 가 정석이다. 아래는 Actions 가 막혔거나 로컬 디버깅 시의 수동 절차다.
|
|
85
|
+
> **Step 10 의 로컬 `npm publish` 는 CI 와 중복이므로 평상시 실행 금지.**
|
|
49
86
|
|
|
50
87
|
### Step 0 — 환경 확인
|
|
51
88
|
|
|
@@ -205,21 +242,15 @@ gh release create "v${TARGET_VERSION}" \
|
|
|
205
242
|
- 노트 본문 검증: Co-Authored-By / AI trailer 포함됐는지 grep 후 제거
|
|
206
243
|
- `--draft` 로 초안 생성 후 수동 publish 도 가능 (안전 모드)
|
|
207
244
|
|
|
208
|
-
### Step 10 — npm publish
|
|
245
|
+
### Step 10 — npm publish (= CI 가 수행, 로컬 금지가 기본)
|
|
209
246
|
|
|
210
|
-
|
|
247
|
+
> **평상시 로컬에서 `npm publish` 하지 않는다.** Step 8 에서 `git push origin v<X.Y.Z>` 로 태그가 올라가면 `npm-publish.yml` 이 OIDC 로 자동 발행한다.
|
|
248
|
+
> CI run 관찰: `gh run list --workflow npm-publish.yml -L1` → `gh run watch <id>`
|
|
211
249
|
|
|
212
|
-
|
|
213
|
-
npm registry 에 배포하시겠습니까?
|
|
214
|
-
|
|
215
|
-
A) node scripts/release/publish.mjs --execute
|
|
216
|
-
B) dry-run 으로 먼저 검증 (node scripts/release/publish.mjs --dry-run)
|
|
217
|
-
C) 건너뜀 (수동 배포)
|
|
218
|
-
```
|
|
250
|
+
CI 가 완전히 불가능한 비상시에만, npm 인증을 갖춘 환경에서 수동 폴백:
|
|
219
251
|
|
|
220
|
-
선택 A:
|
|
221
252
|
```bash
|
|
222
|
-
node scripts/release/publish.mjs --execute
|
|
253
|
+
node scripts/release/publish.mjs --execute # 비상 폴백 전용 — 중복 publish 주의
|
|
223
254
|
```
|
|
224
255
|
|
|
225
256
|
### Step 10.5 — pypi publish (future, 현재 비활성)
|
|
@@ -277,14 +308,14 @@ github: https://github.com/tellang/triflux/releases/tag/v${TARGET_VERSION}
|
|
|
277
308
|
| Step 7 | commit 메시지에 AI trailer 감지 | 하드 차단 + 재작성 요청 |
|
|
278
309
|
| Step 8 | push 거부 (remote 변경됨) | `git pull --rebase origin main` 후 재시도 |
|
|
279
310
|
| Step 9 | gh release create 실패 | `gh auth status` 확인, 수동 재시도 |
|
|
280
|
-
| Step 10 | npm publish 실패 | `npm
|
|
311
|
+
| Step 10 | npm publish(CI) 실패 | `gh run view <id> --log-failed` 로 `npm-publish.yml` 로그 확인. `NPM_TOKEN` secret / OIDC 설정 점검 |
|
|
281
312
|
|
|
282
313
|
## 플래그
|
|
283
314
|
|
|
284
315
|
| 플래그 | 동작 |
|
|
285
316
|
|--------|------|
|
|
286
317
|
| `--skip-tests` | Step 5 의 `npm test` 건너뜀. stderr 경고 출력. 긴급 hotfix 전용 |
|
|
287
|
-
| `--no-publish` |
|
|
318
|
+
| `--no-publish` | 태그를 push 하지 않아 `npm-publish.yml` 미발동. main 커밋 + (수동) GitHub release 만 |
|
|
288
319
|
| `--dry-run` | 모든 git push / publish 호출을 출력만 하고 skip. 검증 전용 |
|
|
289
320
|
|
|
290
321
|
## AI trailer 방지 상세
|
|
@@ -313,6 +344,6 @@ github: https://github.com/tellang/triflux/releases/tag/v${TARGET_VERSION}
|
|
|
313
344
|
|
|
314
345
|
- 버전 불일치: `npm run release:check-sync --fix`
|
|
315
346
|
- pack CRLF 경고: 실제 변경 파일만 선별 `git add packages/triflux/...`
|
|
316
|
-
- gh CLI 미인증: `gh auth login`
|
|
317
|
-
- npm
|
|
347
|
+
- gh CLI 미인증: `gh auth login` (또는 gh 정상인 머신/웹 UI 로 release.yml dispatch)
|
|
348
|
+
- npm publish 는 CI(`npm-publish.yml`, OIDC)가 수행 — 로컬 `npm login` 불필요. CI 실패 시 `gh run view <id> --log-failed`
|
|
318
349
|
- prepare.mjs stall: `scripts/release/prepare.mjs` 가 `stdio: ["ignore","pipe","pipe"]` + 10분 timeout 적용됨 (v10.9.32 fix 739da2d)
|