feishu-codex-console 1.0.0-beta.6
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/.env.example +101 -0
- package/.feishu-codex-policy.example.json +11 -0
- package/.feishu-codex-runbooks.example.json +36 -0
- package/CHANGELOG.md +129 -0
- package/CODE_OF_CONDUCT.md +7 -0
- package/CONTRIBUTING.md +52 -0
- package/LICENSE +21 -0
- package/README.en.md +81 -0
- package/README.md +398 -0
- package/ROADMAP.md +40 -0
- package/SECURITY.md +53 -0
- package/dist/account-quota-card.js +233 -0
- package/dist/account-quota.js +125 -0
- package/dist/app-server-client.js +281 -0
- package/dist/card-session.js +166 -0
- package/dist/codex-events.js +1 -0
- package/dist/codex-runner.js +875 -0
- package/dist/config.js +198 -0
- package/dist/confirmation-card.js +135 -0
- package/dist/control-card.js +345 -0
- package/dist/conversation-turn-session.js +209 -0
- package/dist/data-maintenance.js +71 -0
- package/dist/device-card.js +460 -0
- package/dist/device-health.js +94 -0
- package/dist/diagnostics.js +253 -0
- package/dist/doctor.js +250 -0
- package/dist/fallback-card-session.js +37 -0
- package/dist/health-file.js +75 -0
- package/dist/index.js +4330 -0
- package/dist/lark-cli.js +558 -0
- package/dist/lark-retry.js +34 -0
- package/dist/maintenance.js +140 -0
- package/dist/model-capabilities.js +31 -0
- package/dist/onboarding-card.js +312 -0
- package/dist/permission-lease.js +22 -0
- package/dist/policy.js +506 -0
- package/dist/progress.js +267 -0
- package/dist/project-card.js +303 -0
- package/dist/project-overview-card.js +182 -0
- package/dist/project-overview.js +278 -0
- package/dist/project-policy.js +160 -0
- package/dist/project-registry.js +259 -0
- package/dist/project-status.js +45 -0
- package/dist/project-workspace.js +55 -0
- package/dist/quota-card.js +94 -0
- package/dist/recovery-policy.js +26 -0
- package/dist/redaction.js +67 -0
- package/dist/remote-ready.js +112 -0
- package/dist/response-card.js +139 -0
- package/dist/result-card.js +166 -0
- package/dist/review-card.js +452 -0
- package/dist/runbook-card.js +272 -0
- package/dist/runbooks.js +191 -0
- package/dist/runtime-card.js +337 -0
- package/dist/session-card.js +128 -0
- package/dist/session-naming.js +14 -0
- package/dist/smoke.js +28 -0
- package/dist/state-backup.js +302 -0
- package/dist/state-store.js +874 -0
- package/dist/task-card.js +640 -0
- package/dist/task-center-card.js +176 -0
- package/dist/task-failure.js +43 -0
- package/dist/task-intent.js +76 -0
- package/dist/task-queue.js +187 -0
- package/dist/task-reconciliation.js +80 -0
- package/dist/task-review.js +497 -0
- package/dist/team-card.js +275 -0
- package/dist/team-directory.js +54 -0
- package/dist/team-policy.js +93 -0
- package/dist/types.js +1 -0
- package/dist/version.js +9 -0
- package/dist/workspace-session.js +64 -0
- package/docs/ARCHITECTURE.md +54 -0
- package/docs/COMPATIBILITY.md +55 -0
- package/docs/CONFIGURATION.md +88 -0
- package/docs/DEMO.md +45 -0
- package/docs/GOOD_FIRST_ISSUES.md +23 -0
- package/docs/INSTALLATION.md +207 -0
- package/docs/OPEN_SOURCE_PRODUCT_PLAN.md +113 -0
- package/docs/PRODUCT_REQUIREMENTS_MAP.md +591 -0
- package/docs/RELEASE_CHECKLIST.md +65 -0
- package/docs/TEAM_DEPLOYMENT.md +35 -0
- package/docs/TROUBLESHOOTING.md +130 -0
- package/docs/V4_WORKSPACE_SESSION_FLOW.md +232 -0
- package/docs/requirements/D10_MAINTENANCE_AND_ECOSYSTEM.md +103 -0
- package/docs/requirements/D1_INSTALLATION_AND_FIRST_CONNECTION.md +479 -0
- package/docs/requirements/D2_DEVICE_AND_CONNECTIVITY.md +54 -0
- package/docs/requirements/D3_PROJECTS_AND_SESSIONS.md +107 -0
- package/docs/requirements/D4_REMOTE_TASK_EXECUTION.md +102 -0
- package/docs/requirements/D5_CODEX_NATIVE_INTERACTIONS.md +99 -0
- package/docs/requirements/D6_RESULTS_AND_CODE_REVIEW.md +100 -0
- package/docs/requirements/D7_SECURITY_GOVERNANCE.md +106 -0
- package/docs/requirements/D8_RELIABILITY_AND_RECOVERY.md +182 -0
- package/docs/requirements/D9_TEAM_COLLABORATION.md +129 -0
- package/package.json +76 -0
- package/scripts/capability-probe.mjs +113 -0
- package/scripts/cli.mjs +919 -0
- package/scripts/config-file.mjs +137 -0
- package/scripts/discovery-lib.mjs +78 -0
- package/scripts/install-card.mjs +37 -0
- package/scripts/install-detection.mjs +126 -0
- package/scripts/install-state.mjs +107 -0
- package/scripts/launchd.mjs +161 -0
- package/scripts/migrate-legacy.mjs +97 -0
- package/scripts/package-smoke.mjs +163 -0
- package/scripts/release-dist-tag.mjs +7 -0
- package/scripts/runbook-template.mjs +36 -0
- package/scripts/service-health.mjs +110 -0
- package/scripts/service.mjs +24 -0
- package/scripts/setup-lib.mjs +118 -0
- package/scripts/systemd.mjs +96 -0
- package/scripts/upgrade-lib.mjs +99 -0
- package/scripts/verify-release.mjs +37 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { chmod, copyFile, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function parseEnvEntries(content = "") {
|
|
5
|
+
const entries = {};
|
|
6
|
+
for (const line of content.split(/\r?\n/)) {
|
|
7
|
+
const parsed = parseAssignment(line);
|
|
8
|
+
if (parsed) entries[parsed.key] = decodeEnvValue(parsed.value);
|
|
9
|
+
}
|
|
10
|
+
return entries;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function mergeEnvContent(existing, generated) {
|
|
14
|
+
if (!existing.trim()) return ensureFinalNewline(generated);
|
|
15
|
+
const generatedAssignments = new Map();
|
|
16
|
+
for (const line of generated.split(/\r?\n/)) {
|
|
17
|
+
const parsed = parseAssignment(line);
|
|
18
|
+
if (parsed) generatedAssignments.set(parsed.key, line);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
const output = [];
|
|
23
|
+
for (const line of existing.split(/\r?\n/)) {
|
|
24
|
+
const parsed = parseAssignment(line);
|
|
25
|
+
if (!parsed || !generatedAssignments.has(parsed.key)) {
|
|
26
|
+
output.push(line);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (!seen.has(parsed.key)) output.push(generatedAssignments.get(parsed.key));
|
|
30
|
+
seen.add(parsed.key);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const missing = [...generatedAssignments]
|
|
34
|
+
.filter(([key]) => !seen.has(key))
|
|
35
|
+
.map(([, line]) => line);
|
|
36
|
+
if (missing.length > 0) {
|
|
37
|
+
while (output.at(-1) === "") output.pop();
|
|
38
|
+
output.push("", "# Managed by feishu-codex-console init", ...missing);
|
|
39
|
+
}
|
|
40
|
+
return ensureFinalNewline(output.join("\n"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readEnvFile(file) {
|
|
44
|
+
const content = await readSafeFile(file);
|
|
45
|
+
return content === null ? {} : parseEnvEntries(content);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readEnvContent(file) {
|
|
49
|
+
return await readSafeFile(file);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function writeManagedConfig(file, generated, options = {}) {
|
|
53
|
+
const directory = path.dirname(file);
|
|
54
|
+
await ensurePrivateDirectory(directory);
|
|
55
|
+
const existing = await readSafeFile(file);
|
|
56
|
+
const forceReset = Boolean(options.forceReset);
|
|
57
|
+
const nextContent = existing === null || forceReset
|
|
58
|
+
? ensureFinalNewline(generated)
|
|
59
|
+
: mergeEnvContent(existing, generated);
|
|
60
|
+
let backupFile;
|
|
61
|
+
if (existing !== null) {
|
|
62
|
+
backupFile = `${file}.bak.${timestamp(options.now ?? new Date())}`;
|
|
63
|
+
await copyFile(file, backupFile);
|
|
64
|
+
await chmod(backupFile, 0o600);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const temporary = path.join(
|
|
68
|
+
directory,
|
|
69
|
+
`.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`,
|
|
70
|
+
);
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(temporary, nextContent, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
73
|
+
await chmod(temporary, 0o600);
|
|
74
|
+
await rename(temporary, file);
|
|
75
|
+
await chmod(file, 0o600);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await rm(temporary, { force: true });
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
created: existing === null,
|
|
82
|
+
backupFile,
|
|
83
|
+
entries: parseEnvEntries(nextContent),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readSafeFile(file) {
|
|
88
|
+
try {
|
|
89
|
+
const details = await lstat(file);
|
|
90
|
+
if (!details.isFile() || details.isSymbolicLink()) {
|
|
91
|
+
throw new Error(`Unsafe configuration path: ${file}`);
|
|
92
|
+
}
|
|
93
|
+
return await readFile(file, "utf8");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.code === "ENOENT") return null;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function ensurePrivateDirectory(directory) {
|
|
101
|
+
let created = false;
|
|
102
|
+
let details;
|
|
103
|
+
try {
|
|
104
|
+
details = await lstat(directory);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.code !== "ENOENT") throw error;
|
|
107
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
108
|
+
details = await lstat(directory);
|
|
109
|
+
created = true;
|
|
110
|
+
}
|
|
111
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
112
|
+
throw new Error(`Unsafe configuration directory: ${directory}`);
|
|
113
|
+
}
|
|
114
|
+
if (created) await chmod(directory, 0o700);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseAssignment(line) {
|
|
118
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
119
|
+
return match ? { key: match[1], value: match[2] ?? "" } : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function decodeEnvValue(value) {
|
|
123
|
+
const trimmed = value.trim();
|
|
124
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
125
|
+
return trimmed.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\");
|
|
126
|
+
}
|
|
127
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1);
|
|
128
|
+
return trimmed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ensureFinalNewline(value) {
|
|
132
|
+
return `${value.replace(/\n+$/, "")}\n`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function timestamp(value) {
|
|
136
|
+
return value.toISOString().replace(/[:.]/g, "-");
|
|
137
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function parseDiscoveryEvent(line) {
|
|
4
|
+
let value;
|
|
5
|
+
try {
|
|
6
|
+
value = JSON.parse(line);
|
|
7
|
+
} catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
if (!value || typeof value !== "object") return null;
|
|
11
|
+
if (value.type !== "im.message.receive_v1") return null;
|
|
12
|
+
if (value.sender_type && value.sender_type !== "user") return null;
|
|
13
|
+
if (typeof value.sender_id !== "string" || !/^ou_[A-Za-z0-9_-]+$/.test(value.sender_id)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (typeof value.chat_id !== "string" || !/^oc_[A-Za-z0-9_-]+$/.test(value.chat_id)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
if (value.chat_type !== "p2p" && value.chat_type !== "group") return null;
|
|
20
|
+
return {
|
|
21
|
+
senderId: value.sender_id,
|
|
22
|
+
chatId: value.chat_id,
|
|
23
|
+
chatType: value.chat_type,
|
|
24
|
+
messageId: typeof value.message_id === "string" ? value.message_id : undefined,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function discoveryCommandArgs(timeout = "2m") {
|
|
29
|
+
return [
|
|
30
|
+
"event",
|
|
31
|
+
"consume",
|
|
32
|
+
"im.message.receive_v1",
|
|
33
|
+
"--as",
|
|
34
|
+
"bot",
|
|
35
|
+
"--max-events",
|
|
36
|
+
"1",
|
|
37
|
+
"--timeout",
|
|
38
|
+
timeout,
|
|
39
|
+
"--quiet",
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function discoverFeishuIdentity(cliPath, options = {}) {
|
|
44
|
+
const timeout = options.timeout ?? "2m";
|
|
45
|
+
const cwd = options.cwd ?? process.cwd();
|
|
46
|
+
const child = spawn(cliPath, discoveryCommandArgs(timeout), {
|
|
47
|
+
cwd,
|
|
48
|
+
env: options.env ?? process.env,
|
|
49
|
+
shell: false,
|
|
50
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
51
|
+
});
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
let buffer = "";
|
|
54
|
+
let stderr = "";
|
|
55
|
+
let discovered = null;
|
|
56
|
+
child.stdout.setEncoding("utf8");
|
|
57
|
+
child.stderr.setEncoding("utf8");
|
|
58
|
+
child.stdout.on("data", (chunk) => {
|
|
59
|
+
buffer += chunk;
|
|
60
|
+
while (buffer.includes("\n")) {
|
|
61
|
+
const newline = buffer.indexOf("\n");
|
|
62
|
+
const line = buffer.slice(0, newline).trim();
|
|
63
|
+
buffer = buffer.slice(newline + 1);
|
|
64
|
+
discovered ??= parseDiscoveryEvent(line);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
child.stderr.on("data", (chunk) => {
|
|
68
|
+
stderr += chunk;
|
|
69
|
+
});
|
|
70
|
+
child.once("error", (error) => reject(error));
|
|
71
|
+
child.once("exit", (code) => {
|
|
72
|
+
discovered ??= parseDiscoveryEvent(buffer.trim());
|
|
73
|
+
if (discovered) resolve(discovered);
|
|
74
|
+
else if (code === 0) resolve(null);
|
|
75
|
+
else reject(new Error(stderr.trim() || `lark-cli 退出,状态码 ${code ?? "unknown"}`));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
export async function sendInstallationCard(options) {
|
|
5
|
+
const LarkCliClass = options.LarkCliClass ?? (await import(
|
|
6
|
+
pathToFileURL(path.join(options.packageRoot, "dist", "lark-cli.js")).href
|
|
7
|
+
)).LarkCli;
|
|
8
|
+
const renderOnboardingCard = options.renderOnboardingCard ?? (await import(
|
|
9
|
+
pathToFileURL(path.join(options.packageRoot, "dist", "onboarding-card.js")).href
|
|
10
|
+
)).renderOnboardingCard;
|
|
11
|
+
const lark = new LarkCliClass({
|
|
12
|
+
binary: options.larkCliPath,
|
|
13
|
+
cwd: options.packageRoot,
|
|
14
|
+
maxReplyChars: 12_000,
|
|
15
|
+
});
|
|
16
|
+
const card = renderOnboardingCard({
|
|
17
|
+
role: "admin",
|
|
18
|
+
state: {
|
|
19
|
+
ownerId: options.ownerId,
|
|
20
|
+
status: "active",
|
|
21
|
+
step: 1,
|
|
22
|
+
updatedAt: new Date().toISOString(),
|
|
23
|
+
},
|
|
24
|
+
projectName: options.projectName,
|
|
25
|
+
modelLabel: "Codex 默认",
|
|
26
|
+
sandboxLabel: options.sandboxLabel,
|
|
27
|
+
deviceOnline: true,
|
|
28
|
+
feedback: "安装成功,飞书、本地服务和 CardKit 已完成端到端验证。",
|
|
29
|
+
});
|
|
30
|
+
const cardId = await lark.createCard(card);
|
|
31
|
+
await lark.replyCard(
|
|
32
|
+
options.messageId,
|
|
33
|
+
cardId,
|
|
34
|
+
`install_${options.instanceId}_${options.messageId}`,
|
|
35
|
+
);
|
|
36
|
+
return { cardId };
|
|
37
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { serviceHealthPath } from "./service-health.mjs";
|
|
7
|
+
|
|
8
|
+
export async function detectExistingInstallation({
|
|
9
|
+
configFile,
|
|
10
|
+
dataDir,
|
|
11
|
+
instanceId,
|
|
12
|
+
platform = process.platform,
|
|
13
|
+
homeDir = homedir(),
|
|
14
|
+
uid = typeof process.getuid === "function" ? process.getuid() : 0,
|
|
15
|
+
run = runCommand,
|
|
16
|
+
}) {
|
|
17
|
+
const config = await fileState(configFile);
|
|
18
|
+
const data = await directoryState(dataDir);
|
|
19
|
+
const health = await healthState(dataDir);
|
|
20
|
+
const service = await serviceState({ instanceId, platform, homeDir, uid, run });
|
|
21
|
+
return {
|
|
22
|
+
exists: config.exists || data.exists || service.installed || health.exists,
|
|
23
|
+
config,
|
|
24
|
+
data,
|
|
25
|
+
health,
|
|
26
|
+
service,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function serviceState({ instanceId, platform, homeDir, uid, run }) {
|
|
31
|
+
if (platform === "darwin") {
|
|
32
|
+
const label = `com.feishu-codex-bridge.${instanceId}`;
|
|
33
|
+
const definition = path.join(homeDir, "Library", "LaunchAgents", `${label}.plist`);
|
|
34
|
+
const definitionState = await fileState(definition);
|
|
35
|
+
const outcome = run("launchctl", ["print", `gui/${uid}/${label}`]);
|
|
36
|
+
return {
|
|
37
|
+
manager: "launchd",
|
|
38
|
+
name: label,
|
|
39
|
+
definition,
|
|
40
|
+
installed: definitionState.exists || outcome.status === 0,
|
|
41
|
+
running: outcome.status === 0,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (platform === "linux") {
|
|
45
|
+
const name = `feishu-codex-bridge-${instanceId}.service`;
|
|
46
|
+
const definition = path.join(homeDir, ".config", "systemd", "user", name);
|
|
47
|
+
const definitionState = await fileState(definition);
|
|
48
|
+
const outcome = run("systemctl", ["--user", "is-active", "--quiet", name]);
|
|
49
|
+
return {
|
|
50
|
+
manager: "systemd",
|
|
51
|
+
name,
|
|
52
|
+
definition,
|
|
53
|
+
installed: definitionState.exists || outcome.status === 0,
|
|
54
|
+
running: outcome.status === 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
manager: "unsupported",
|
|
59
|
+
name: "",
|
|
60
|
+
definition: "",
|
|
61
|
+
installed: false,
|
|
62
|
+
running: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fileState(file) {
|
|
67
|
+
try {
|
|
68
|
+
const details = await lstat(file);
|
|
69
|
+
return {
|
|
70
|
+
path: file,
|
|
71
|
+
exists: details.isFile() && !details.isSymbolicLink(),
|
|
72
|
+
unsafe: !details.isFile() || details.isSymbolicLink(),
|
|
73
|
+
};
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.code === "ENOENT") return { path: file, exists: false, unsafe: false };
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function directoryState(directory) {
|
|
81
|
+
try {
|
|
82
|
+
const details = await lstat(directory);
|
|
83
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
84
|
+
return { path: directory, exists: true, unsafe: true, entryCount: 0 };
|
|
85
|
+
}
|
|
86
|
+
const entries = await readdir(directory);
|
|
87
|
+
return {
|
|
88
|
+
path: directory,
|
|
89
|
+
exists: true,
|
|
90
|
+
unsafe: false,
|
|
91
|
+
entryCount: entries.length,
|
|
92
|
+
};
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error?.code === "ENOENT") {
|
|
95
|
+
return { path: directory, exists: false, unsafe: false, entryCount: 0 };
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function healthState(dataDir) {
|
|
102
|
+
const file = serviceHealthPath(dataDir);
|
|
103
|
+
const state = await fileState(file);
|
|
104
|
+
if (!state.exists) return { path: file, exists: false };
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
107
|
+
return {
|
|
108
|
+
path: file,
|
|
109
|
+
exists: true,
|
|
110
|
+
status: typeof parsed.status === "string" ? parsed.status : "unknown",
|
|
111
|
+
pid: Number.isSafeInteger(parsed.pid) ? parsed.pid : null,
|
|
112
|
+
instanceId: typeof parsed.instanceId === "string" ? parsed.instanceId : null,
|
|
113
|
+
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null,
|
|
114
|
+
};
|
|
115
|
+
} catch {
|
|
116
|
+
return { path: file, exists: true, status: "invalid", pid: null, instanceId: null, updatedAt: null };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function runCommand(command, args) {
|
|
121
|
+
const outcome = spawnSync(command, args, {
|
|
122
|
+
env: process.env,
|
|
123
|
+
stdio: "ignore",
|
|
124
|
+
});
|
|
125
|
+
return { status: outcome.status ?? 1 };
|
|
126
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const INSTALL_STEPS = Object.freeze([
|
|
5
|
+
"environment_ready",
|
|
6
|
+
"feishu_bound",
|
|
7
|
+
"capabilities_verified",
|
|
8
|
+
"identity_discovered",
|
|
9
|
+
"preferences_confirmed",
|
|
10
|
+
"config_written",
|
|
11
|
+
"service_running",
|
|
12
|
+
"test_card_delivered",
|
|
13
|
+
"completed",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export function installStatePath(configFile) {
|
|
17
|
+
return configFile.endsWith(".env")
|
|
18
|
+
? `${configFile.slice(0, -4)}.install-state.json`
|
|
19
|
+
: `${configFile}.install-state.json`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function loadInstallState(configFile) {
|
|
23
|
+
const file = installStatePath(configFile);
|
|
24
|
+
try {
|
|
25
|
+
const details = await lstat(file);
|
|
26
|
+
if (!details.isFile() || details.isSymbolicLink()) throw new Error(`Unsafe install state: ${file}`);
|
|
27
|
+
const value = JSON.parse(await readFile(file, "utf8"));
|
|
28
|
+
if (value?.version !== 1 || !Array.isArray(value.completedSteps)) {
|
|
29
|
+
throw new Error(`Unsupported install state format: ${file}`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error?.code === "ENOENT") return null;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function recordInstallStep(configFile, previous, step, details = {}) {
|
|
39
|
+
if (!INSTALL_STEPS.includes(step)) throw new Error(`Unknown install step: ${step}`);
|
|
40
|
+
const completedSteps = [...new Set([...(previous?.completedSteps ?? []), step])]
|
|
41
|
+
.sort((left, right) => INSTALL_STEPS.indexOf(left) - INSTALL_STEPS.indexOf(right));
|
|
42
|
+
const previousIndex = INSTALL_STEPS.indexOf(previous?.status);
|
|
43
|
+
const stepIndex = INSTALL_STEPS.indexOf(step);
|
|
44
|
+
const status = previousIndex > stepIndex ? previous.status : step;
|
|
45
|
+
const state = {
|
|
46
|
+
version: 1,
|
|
47
|
+
status,
|
|
48
|
+
completedSteps,
|
|
49
|
+
configFile: path.resolve(configFile),
|
|
50
|
+
updatedAt: new Date().toISOString(),
|
|
51
|
+
...safeDetails(previous ?? {}),
|
|
52
|
+
...safeDetails(details),
|
|
53
|
+
};
|
|
54
|
+
await writeInstallState(configFile, state);
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function recordInstallError(configFile, previous, error) {
|
|
59
|
+
const state = {
|
|
60
|
+
version: 1,
|
|
61
|
+
status: previous?.status ?? "not_started",
|
|
62
|
+
completedSteps: previous?.completedSteps ?? [],
|
|
63
|
+
configFile: path.resolve(configFile),
|
|
64
|
+
updatedAt: new Date().toISOString(),
|
|
65
|
+
...safeDetails(previous ?? {}),
|
|
66
|
+
lastError: String(error instanceof Error ? error.message : error).slice(0, 500),
|
|
67
|
+
};
|
|
68
|
+
await writeInstallState(configFile, state);
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function writeInstallState(configFile, state) {
|
|
73
|
+
const file = installStatePath(configFile);
|
|
74
|
+
const directory = path.dirname(file);
|
|
75
|
+
let created = false;
|
|
76
|
+
try {
|
|
77
|
+
const details = await lstat(directory);
|
|
78
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
79
|
+
throw new Error(`Unsafe install state directory: ${directory}`);
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error?.code !== "ENOENT") throw error;
|
|
83
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
84
|
+
created = true;
|
|
85
|
+
}
|
|
86
|
+
if (created) await chmod(directory, 0o700);
|
|
87
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
88
|
+
try {
|
|
89
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, {
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
mode: 0o600,
|
|
92
|
+
flag: "wx",
|
|
93
|
+
});
|
|
94
|
+
await rename(temporary, file);
|
|
95
|
+
await chmod(file, 0o600);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
await rm(temporary, { force: true });
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function safeDetails(details) {
|
|
103
|
+
const allowed = ["instanceId", "preset", "chatType", "serviceInstalled", "testCardDisabled"];
|
|
104
|
+
return Object.fromEntries(
|
|
105
|
+
Object.entries(details).filter(([key, value]) => allowed.includes(key) && value !== undefined),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { chmod, lstat, mkdir, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const projectDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const instanceId = safeInstanceId(process.env.BRIDGE_INSTANCE_ID);
|
|
11
|
+
const configFile = process.env.DOTENV_CONFIG_PATH?.trim();
|
|
12
|
+
const label = `com.feishu-codex-bridge.${instanceId}`;
|
|
13
|
+
const legacyLabels = ["com.leochaseyoung.feishu-codex-bridge"];
|
|
14
|
+
const agentDir = path.join(homedir(), "Library", "LaunchAgents");
|
|
15
|
+
const plistPath = path.join(agentDir, `${label}.plist`);
|
|
16
|
+
const domain = `gui/${process.getuid()}`;
|
|
17
|
+
const service = `${domain}/${label}`;
|
|
18
|
+
const action = process.argv[2] ?? "status";
|
|
19
|
+
|
|
20
|
+
if (process.platform !== "darwin") {
|
|
21
|
+
console.error("LaunchAgent 仅支持 macOS。");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (action === "install") {
|
|
26
|
+
await install();
|
|
27
|
+
} else if (action === "uninstall") {
|
|
28
|
+
await uninstall();
|
|
29
|
+
} else if (action === "status") {
|
|
30
|
+
const result = launchctl(["print", service], true);
|
|
31
|
+
process.exitCode = result.status === 0 ? 0 : 1;
|
|
32
|
+
} else if (action === "stop") {
|
|
33
|
+
const result = launchctl(["bootout", service], false);
|
|
34
|
+
process.exitCode = result.status === 0 ? 0 : 1;
|
|
35
|
+
if (result.status === 0) console.log(`已停止 ${label}`);
|
|
36
|
+
} else if (action === "restart") {
|
|
37
|
+
const result = launchctl(["kickstart", "-k", service], true);
|
|
38
|
+
process.exitCode = result.status === 0 ? 0 : 1;
|
|
39
|
+
if (result.status === 0) console.log(`已重启 ${label}`);
|
|
40
|
+
} else {
|
|
41
|
+
console.error("用法:node scripts/launchd.mjs install|uninstall|status|stop|restart");
|
|
42
|
+
process.exitCode = 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function install() {
|
|
46
|
+
const dataDir = path.resolve(process.env.BRIDGE_DATA_DIR || path.join(projectDir, "var"));
|
|
47
|
+
const logDir = path.join(dataDir, "log");
|
|
48
|
+
await mkdir(agentDir, { recursive: true });
|
|
49
|
+
await ensurePrivateDirectory(logDir);
|
|
50
|
+
await writeFile(plistPath, plist(logDir), { encoding: "utf8", mode: 0o644 });
|
|
51
|
+
await chmod(plistPath, 0o644);
|
|
52
|
+
launchctl(["bootout", service], false);
|
|
53
|
+
for (const legacyLabel of legacyLabels) {
|
|
54
|
+
const legacyService = `${domain}/${legacyLabel}`;
|
|
55
|
+
launchctl(["bootout", legacyService], false);
|
|
56
|
+
await rm(path.join(agentDir, `${legacyLabel}.plist`), { force: true });
|
|
57
|
+
}
|
|
58
|
+
await delay(750);
|
|
59
|
+
let boot = launchctl(["bootstrap", domain, plistPath], true);
|
|
60
|
+
for (let attempt = 1; boot.status !== 0 && attempt < 3; attempt += 1) {
|
|
61
|
+
await delay(750 * (attempt + 1));
|
|
62
|
+
boot = launchctl(["bootstrap", domain, plistPath], true);
|
|
63
|
+
}
|
|
64
|
+
if (boot.status !== 0) process.exit(boot.status ?? 1);
|
|
65
|
+
launchctl(["enable", service], false);
|
|
66
|
+
// Bootstrap + RunAtLoad already starts the service. A non-destructive kickstart
|
|
67
|
+
// covers slower launchd versions without racing a second bridge process against
|
|
68
|
+
// the first one while its Feishu subscriptions are still being registered.
|
|
69
|
+
const start = launchctl(["kickstart", service], true);
|
|
70
|
+
if (start.status !== 0) process.exit(start.status ?? 1);
|
|
71
|
+
console.log(`已安装并启动 ${label}`);
|
|
72
|
+
console.log(`日志目录:${logDir}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function ensurePrivateDirectory(directory) {
|
|
76
|
+
try {
|
|
77
|
+
const details = await lstat(directory);
|
|
78
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
79
|
+
throw new Error(`Unsafe service directory: ${directory}`);
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error?.code !== "ENOENT") throw error;
|
|
83
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
84
|
+
}
|
|
85
|
+
await chmod(directory, 0o700);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function delay(milliseconds) {
|
|
89
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function uninstall() {
|
|
93
|
+
launchctl(["bootout", service], false);
|
|
94
|
+
await rm(plistPath, { force: true });
|
|
95
|
+
for (const legacyLabel of legacyLabels) {
|
|
96
|
+
launchctl(["bootout", `${domain}/${legacyLabel}`], false);
|
|
97
|
+
await rm(path.join(agentDir, `${legacyLabel}.plist`), { force: true });
|
|
98
|
+
}
|
|
99
|
+
console.log(`已卸载 ${label}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function launchctl(args, inherit) {
|
|
103
|
+
return spawnSync("launchctl", args, {
|
|
104
|
+
cwd: projectDir,
|
|
105
|
+
env: process.env,
|
|
106
|
+
encoding: "utf8",
|
|
107
|
+
stdio: inherit ? "inherit" : "ignore",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function plist(logDir) {
|
|
112
|
+
const environmentPath = process.env.PATH ?? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
|
|
113
|
+
const configEnvironment = configFile
|
|
114
|
+
? `\n <key>DOTENV_CONFIG_PATH</key><string>${xml(path.resolve(configFile))}</string>`
|
|
115
|
+
: "";
|
|
116
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
117
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
118
|
+
<plist version="1.0">
|
|
119
|
+
<dict>
|
|
120
|
+
<key>Label</key><string>${xml(label)}</string>
|
|
121
|
+
<key>ProgramArguments</key>
|
|
122
|
+
<array>
|
|
123
|
+
<string>${xml(process.execPath)}</string>
|
|
124
|
+
<string>--disable-warning=ExperimentalWarning</string>
|
|
125
|
+
<string>${xml(path.join(projectDir, "dist", "index.js"))}</string>
|
|
126
|
+
</array>
|
|
127
|
+
<key>WorkingDirectory</key><string>${xml(projectDir)}</string>
|
|
128
|
+
<key>EnvironmentVariables</key>
|
|
129
|
+
<dict>
|
|
130
|
+
<key>NODE_ENV</key><string>production</string>
|
|
131
|
+
<key>PATH</key><string>${xml(environmentPath)}</string>${configEnvironment}
|
|
132
|
+
</dict>
|
|
133
|
+
<key>RunAtLoad</key><true/>
|
|
134
|
+
<key>KeepAlive</key><true/>
|
|
135
|
+
<key>ThrottleInterval</key><integer>5</integer>
|
|
136
|
+
<key>ProcessType</key><string>Interactive</string>
|
|
137
|
+
<key>StandardOutPath</key><string>${xml(path.join(logDir, "bridge.log"))}</string>
|
|
138
|
+
<key>StandardErrorPath</key><string>${xml(path.join(logDir, "bridge.error.log"))}</string>
|
|
139
|
+
</dict>
|
|
140
|
+
</plist>
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function xml(value) {
|
|
145
|
+
return value
|
|
146
|
+
.replaceAll("&", "&")
|
|
147
|
+
.replaceAll("<", "<")
|
|
148
|
+
.replaceAll(">", ">")
|
|
149
|
+
.replaceAll('"', """)
|
|
150
|
+
.replaceAll("'", "'");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function safeInstanceId(value) {
|
|
154
|
+
const normalized = (value?.trim() || "default")
|
|
155
|
+
.toLowerCase()
|
|
156
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
157
|
+
.replace(/^-+|-+$/g, "")
|
|
158
|
+
.slice(0, 40);
|
|
159
|
+
if (!normalized) throw new Error("BRIDGE_INSTANCE_ID must contain letters or numbers");
|
|
160
|
+
return normalized;
|
|
161
|
+
}
|