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,97 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
mergeEnvContent,
|
|
5
|
+
parseEnvEntries,
|
|
6
|
+
readEnvContent,
|
|
7
|
+
writeManagedConfig,
|
|
8
|
+
} from "./config-file.mjs";
|
|
9
|
+
import { defaultSetupPaths, envValue, normalizeInstanceId, splitCsv, validateOpenIds } from "./setup-lib.mjs";
|
|
10
|
+
|
|
11
|
+
const SINGLE_PATH_KEYS = [
|
|
12
|
+
"CODEX_PROJECT_STATE_FILE",
|
|
13
|
+
"BRIDGE_DATABASE_FILE",
|
|
14
|
+
"BRIDGE_STATE_FILE",
|
|
15
|
+
"CODEX_CLI_PATH",
|
|
16
|
+
"LARK_CLI_PATH",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export async function migrateLegacyInstallation({
|
|
20
|
+
sourceDir,
|
|
21
|
+
sourceConfig,
|
|
22
|
+
targetConfig,
|
|
23
|
+
instance,
|
|
24
|
+
home,
|
|
25
|
+
allowExisting = false,
|
|
26
|
+
now,
|
|
27
|
+
}) {
|
|
28
|
+
const legacyRoot = path.resolve(sourceDir);
|
|
29
|
+
const legacyConfig = sourceConfig
|
|
30
|
+
? path.isAbsolute(sourceConfig)
|
|
31
|
+
? path.normalize(sourceConfig)
|
|
32
|
+
: path.resolve(legacyRoot, sourceConfig)
|
|
33
|
+
: path.join(legacyRoot, ".env");
|
|
34
|
+
const sourceContent = await readEnvContent(legacyConfig);
|
|
35
|
+
if (sourceContent === null) throw new Error(`没有找到旧源码配置:${legacyConfig}`);
|
|
36
|
+
const entries = parseEnvEntries(sourceContent);
|
|
37
|
+
validateLegacyEntries(entries, legacyConfig);
|
|
38
|
+
|
|
39
|
+
const instanceId = normalizeInstanceId(instance ?? entries.BRIDGE_INSTANCE_ID ?? "default");
|
|
40
|
+
const defaults = defaultSetupPaths(instanceId, home);
|
|
41
|
+
const destination = path.resolve(targetConfig ?? defaults.configFile);
|
|
42
|
+
if (destination === legacyConfig) {
|
|
43
|
+
throw new Error("迁移目标不能与旧源码 .env 相同。请使用新的用户配置目录。");
|
|
44
|
+
}
|
|
45
|
+
const existingTarget = await readEnvContent(destination);
|
|
46
|
+
if (existingTarget !== null && !allowExisting) {
|
|
47
|
+
throw new Error(`目标配置已存在:${destination}。确认合并时添加 --force。`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const dataDir = resolveLegacyPath(entries.BRIDGE_DATA_DIR, legacyRoot, path.join(legacyRoot, "var"));
|
|
51
|
+
const workdir = resolveLegacyPath(entries.CODEX_WORKDIR, legacyRoot);
|
|
52
|
+
const overrides = [
|
|
53
|
+
"# Migrated by feishu-codex-console. The legacy source directory remains unchanged.",
|
|
54
|
+
`BRIDGE_INSTANCE_ID=${envValue(instanceId)}`,
|
|
55
|
+
`BRIDGE_DATA_DIR=${envValue(dataDir)}`,
|
|
56
|
+
`CODEX_WORKDIR=${envValue(workdir)}`,
|
|
57
|
+
];
|
|
58
|
+
const roots = splitCsv(entries.CODEX_PROJECT_ROOTS ?? "").map((root) =>
|
|
59
|
+
resolveLegacyPath(root, legacyRoot),
|
|
60
|
+
);
|
|
61
|
+
if (roots.length > 0) overrides.push(`CODEX_PROJECT_ROOTS=${envValue(roots.join(","))}`);
|
|
62
|
+
for (const key of SINGLE_PATH_KEYS) {
|
|
63
|
+
const value = entries[key]?.trim();
|
|
64
|
+
if (value) overrides.push(`${key}=${envValue(resolveLegacyPath(value, legacyRoot))}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const migratedContent = mergeEnvContent(sourceContent, `${overrides.join("\n")}\n`);
|
|
68
|
+
const writeResult = await writeManagedConfig(destination, migratedContent, {
|
|
69
|
+
...(now ? { now } : {}),
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
sourceDir: legacyRoot,
|
|
73
|
+
sourceConfig: legacyConfig,
|
|
74
|
+
targetConfig: destination,
|
|
75
|
+
instanceId,
|
|
76
|
+
dataDir,
|
|
77
|
+
workdir,
|
|
78
|
+
reusedLegacyData: true,
|
|
79
|
+
backupFile: writeResult.backupFile,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateLegacyEntries(entries, configFile) {
|
|
84
|
+
if (!entries.ALLOWED_FEISHU_OPEN_IDS?.trim()) {
|
|
85
|
+
throw new Error(`旧配置缺少 ALLOWED_FEISHU_OPEN_IDS:${configFile}`);
|
|
86
|
+
}
|
|
87
|
+
validateOpenIds(entries.ALLOWED_FEISHU_OPEN_IDS, "旧配置操作者 open_id");
|
|
88
|
+
if (!entries.CODEX_WORKDIR?.trim()) {
|
|
89
|
+
throw new Error(`旧配置缺少 CODEX_WORKDIR:${configFile}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveLegacyPath(value, legacyRoot, fallback) {
|
|
94
|
+
const selected = value?.trim() || fallback;
|
|
95
|
+
if (!selected) throw new Error("旧配置包含空路径。");
|
|
96
|
+
return path.isAbsolute(selected) ? path.normalize(selected) : path.resolve(legacyRoot, selected);
|
|
97
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const sandbox = await mkdtemp(path.join(tmpdir(), "feishu-codex-bridge-package-"));
|
|
9
|
+
let archive;
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
archive = pack();
|
|
13
|
+
const archivePath = path.join(packageRoot, archive);
|
|
14
|
+
const installDir = path.join(sandbox, "consumer");
|
|
15
|
+
const configFile = path.join(sandbox, "config", "smoke.env");
|
|
16
|
+
const dataDir = path.join(sandbox, "data");
|
|
17
|
+
|
|
18
|
+
run("npm", [
|
|
19
|
+
"install",
|
|
20
|
+
"--ignore-scripts",
|
|
21
|
+
"--no-audit",
|
|
22
|
+
"--no-fund",
|
|
23
|
+
"--prefix",
|
|
24
|
+
installDir,
|
|
25
|
+
archivePath,
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const cli = path.join(installDir, "node_modules", ".bin", "feishu-codex-console");
|
|
29
|
+
const compatibilityCli = path.join(installDir, "node_modules", ".bin", "feishu-codex-bridge");
|
|
30
|
+
await access(cli);
|
|
31
|
+
await access(compatibilityCli);
|
|
32
|
+
run(cli, ["help"], installDir);
|
|
33
|
+
run(compatibilityCli, ["help"], installDir);
|
|
34
|
+
const versionInfo = JSON.parse(run(cli, ["version", "--json"], installDir));
|
|
35
|
+
if (versionInfo.product !== "feishu-codex-console" || !versionInfo.version) {
|
|
36
|
+
throw new Error("Installed CLI did not expose version and compatibility metadata.");
|
|
37
|
+
}
|
|
38
|
+
runExpectFailure(
|
|
39
|
+
cli,
|
|
40
|
+
[
|
|
41
|
+
"init",
|
|
42
|
+
"--yes",
|
|
43
|
+
"--no-service",
|
|
44
|
+
"--skip-feishu-check",
|
|
45
|
+
"--preset",
|
|
46
|
+
"personal",
|
|
47
|
+
"--open-id",
|
|
48
|
+
"ou_package_smoke",
|
|
49
|
+
"--workdir",
|
|
50
|
+
path.join(sandbox, "missing-workdir"),
|
|
51
|
+
"--config",
|
|
52
|
+
configFile,
|
|
53
|
+
"--data-dir",
|
|
54
|
+
dataDir,
|
|
55
|
+
],
|
|
56
|
+
installDir,
|
|
57
|
+
);
|
|
58
|
+
const interruptedStateFile = configFile.replace(/\.env$/, ".install-state.json");
|
|
59
|
+
const interruptedState = JSON.parse(await readFile(interruptedStateFile, "utf8"));
|
|
60
|
+
if (interruptedState.status !== "environment_ready" || !interruptedState.lastError) {
|
|
61
|
+
throw new Error("Interrupted installation did not persist a resumable safe checkpoint.");
|
|
62
|
+
}
|
|
63
|
+
run(
|
|
64
|
+
cli,
|
|
65
|
+
[
|
|
66
|
+
"init",
|
|
67
|
+
"--yes",
|
|
68
|
+
"--no-service",
|
|
69
|
+
"--skip-feishu-check",
|
|
70
|
+
"--preset",
|
|
71
|
+
"personal",
|
|
72
|
+
"--open-id",
|
|
73
|
+
"ou_package_smoke",
|
|
74
|
+
"--workdir",
|
|
75
|
+
packageRoot,
|
|
76
|
+
"--config",
|
|
77
|
+
configFile,
|
|
78
|
+
"--data-dir",
|
|
79
|
+
dataDir,
|
|
80
|
+
],
|
|
81
|
+
installDir,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const config = await readFile(configFile, "utf8");
|
|
85
|
+
if (!config.includes("ALLOWED_FEISHU_OPEN_IDS=ou_package_smoke")) {
|
|
86
|
+
throw new Error("Installed CLI did not generate the expected configuration.");
|
|
87
|
+
}
|
|
88
|
+
const resumedState = JSON.parse(await readFile(interruptedStateFile, "utf8"));
|
|
89
|
+
if (!resumedState.completedSteps?.includes("config_written")) {
|
|
90
|
+
throw new Error("Second initialization did not resume through configuration write.");
|
|
91
|
+
}
|
|
92
|
+
const runbookProject = path.join(sandbox, "runbook-project");
|
|
93
|
+
await mkdir(runbookProject);
|
|
94
|
+
run(cli, ["init-runbooks", "--project", runbookProject], installDir);
|
|
95
|
+
const runbookFile = path.join(runbookProject, ".feishu-codex-runbooks.json");
|
|
96
|
+
const runbookCatalog = JSON.parse(await readFile(runbookFile, "utf8"));
|
|
97
|
+
if (runbookCatalog.version !== 1 || !Array.isArray(runbookCatalog.runbooks)) {
|
|
98
|
+
throw new Error("Installed package did not create a valid runbook template.");
|
|
99
|
+
}
|
|
100
|
+
await writeFile(runbookFile, '{"teamOwned":true}\n');
|
|
101
|
+
run(cli, ["init-runbooks", "--project", runbookProject], installDir);
|
|
102
|
+
if ((await readFile(runbookFile, "utf8")) !== '{"teamOwned":true}\n') {
|
|
103
|
+
throw new Error("Runbook initialization overwrote an existing team catalog.");
|
|
104
|
+
}
|
|
105
|
+
const upgradePreview = run(cli, ["upgrade", "--config", configFile], installDir);
|
|
106
|
+
if (!upgradePreview.includes("未做任何修改")) {
|
|
107
|
+
throw new Error("Safe upgrade preview did not remain read-only without --yes.");
|
|
108
|
+
}
|
|
109
|
+
const backupList = run(cli, ["backups", "--config", configFile], installDir);
|
|
110
|
+
if (!backupList.includes("可用运行数据备份(0)")) {
|
|
111
|
+
throw new Error("Installed CLI could not inspect runtime backups.");
|
|
112
|
+
}
|
|
113
|
+
if (process.platform !== "win32") {
|
|
114
|
+
const configMode = (await stat(configFile)).mode & 0o777;
|
|
115
|
+
const dataMode = (await stat(dataDir)).mode & 0o777;
|
|
116
|
+
if (configMode !== 0o600) throw new Error(`Config mode is ${configMode.toString(8)}, expected 600.`);
|
|
117
|
+
if (dataMode !== 0o700) throw new Error(`Data mode is ${dataMode.toString(8)}, expected 700.`);
|
|
118
|
+
}
|
|
119
|
+
console.log("Package smoke test passed: packed, installed, resumed, initialized runbooks, and previewed a safe upgrade.");
|
|
120
|
+
} finally {
|
|
121
|
+
await rm(sandbox, { recursive: true, force: true });
|
|
122
|
+
if (archive) await rm(path.join(packageRoot, archive), { force: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function pack() {
|
|
126
|
+
const result = spawnSync("npm", ["pack", "--silent"], {
|
|
127
|
+
cwd: packageRoot,
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
130
|
+
});
|
|
131
|
+
if (result.status !== 0) throw new Error("npm pack failed.");
|
|
132
|
+
const filename = result.stdout.trim().split("\n").at(-1);
|
|
133
|
+
if (!filename?.endsWith(".tgz")) throw new Error(`Unexpected npm pack output: ${result.stdout}`);
|
|
134
|
+
return filename;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function run(command, args, cwd = packageRoot) {
|
|
138
|
+
const result = spawnSync(command, args, {
|
|
139
|
+
cwd,
|
|
140
|
+
env: process.env,
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
stdio: "pipe",
|
|
143
|
+
});
|
|
144
|
+
if (result.status !== 0) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`${command} ${args.join(" ")} failed.\n${result.stdout ?? ""}\n${result.stderr ?? ""}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return result.stdout;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function runExpectFailure(command, args, cwd = packageRoot) {
|
|
153
|
+
const result = spawnSync(command, args, {
|
|
154
|
+
cwd,
|
|
155
|
+
env: process.env,
|
|
156
|
+
encoding: "utf8",
|
|
157
|
+
stdio: "pipe",
|
|
158
|
+
});
|
|
159
|
+
if (result.status === 0) {
|
|
160
|
+
throw new Error(`${command} ${args.join(" ")} unexpectedly succeeded.`);
|
|
161
|
+
}
|
|
162
|
+
return `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
163
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
6
|
+
const manifest = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
|
|
7
|
+
console.log(String(manifest.version).includes("-") ? "next" : "latest");
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { chmod, copyFile, lstat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const RUNBOOK_TEMPLATE_NAME = ".feishu-codex-runbooks.json";
|
|
6
|
+
|
|
7
|
+
export async function initializeRunbookTemplate({ projectDir, sourceFile }) {
|
|
8
|
+
const project = path.resolve(projectDir);
|
|
9
|
+
const source = path.resolve(sourceFile);
|
|
10
|
+
await assertDirectory(project, "项目目录");
|
|
11
|
+
await assertRegularFile(source, "运行手册示例");
|
|
12
|
+
const target = path.join(project, RUNBOOK_TEMPLATE_NAME);
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
await copyFile(source, target, constants.COPYFILE_EXCL);
|
|
16
|
+
await chmod(target, 0o644);
|
|
17
|
+
return { created: true, target };
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error?.code === "EEXIST") return { created: false, target };
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function assertDirectory(directory, label) {
|
|
25
|
+
const details = await lstat(directory).catch(() => null);
|
|
26
|
+
if (!details?.isDirectory() || details.isSymbolicLink()) {
|
|
27
|
+
throw new Error(`${label}不存在或不是安全目录:${directory}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function assertRegularFile(file, label) {
|
|
32
|
+
const details = await lstat(file).catch(() => null);
|
|
33
|
+
if (!details?.isFile() || details.isSymbolicLink()) {
|
|
34
|
+
throw new Error(`${label}不存在或不是安全文件:${file}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const BRIDGE_HEALTH_FILE = "bridge-health.json";
|
|
5
|
+
|
|
6
|
+
export function serviceHealthPath(dataDir) {
|
|
7
|
+
return path.join(dataDir, BRIDGE_HEALTH_FILE);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function readServiceHealth(dataDir) {
|
|
11
|
+
const file = serviceHealthPath(dataDir);
|
|
12
|
+
const details = await lstat(file);
|
|
13
|
+
if (!details.isFile() || details.isSymbolicLink()) {
|
|
14
|
+
throw new Error(`服务健康文件不安全:${file}`);
|
|
15
|
+
}
|
|
16
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateServiceHealth(
|
|
20
|
+
health,
|
|
21
|
+
{
|
|
22
|
+
instanceId,
|
|
23
|
+
configFile,
|
|
24
|
+
now = Date.now(),
|
|
25
|
+
maxHeartbeatAgeMs = 45_000,
|
|
26
|
+
processAlive = isProcessAlive,
|
|
27
|
+
},
|
|
28
|
+
) {
|
|
29
|
+
if (!health || typeof health !== "object" || Array.isArray(health)) {
|
|
30
|
+
return invalid("健康文件格式无效");
|
|
31
|
+
}
|
|
32
|
+
if (health.version !== 1) return invalid("健康文件版本不受支持");
|
|
33
|
+
if (health.status !== "ready") {
|
|
34
|
+
return invalid(`服务尚未就绪(当前状态:${String(health.status ?? "unknown")})`);
|
|
35
|
+
}
|
|
36
|
+
if (health.instanceId !== instanceId) {
|
|
37
|
+
return invalid(`服务实例不一致(期望 ${instanceId},实际 ${String(health.instanceId)})`);
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isSafeInteger(health.pid) || health.pid <= 0 || !processAlive(health.pid)) {
|
|
40
|
+
return invalid("健康文件对应的后台进程不存在");
|
|
41
|
+
}
|
|
42
|
+
if (typeof health.configFile !== "string" || health.configFile.length === 0) {
|
|
43
|
+
return invalid("后台服务没有报告配置文件路径");
|
|
44
|
+
}
|
|
45
|
+
if (path.resolve(health.configFile) !== path.resolve(configFile)) {
|
|
46
|
+
return invalid(
|
|
47
|
+
`后台服务读取了另一份配置(期望 ${path.resolve(configFile)},实际 ${path.resolve(health.configFile)})`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const updatedAt = Date.parse(health.updatedAt);
|
|
51
|
+
if (!Number.isFinite(updatedAt)) return invalid("健康时间戳无效");
|
|
52
|
+
if (now - updatedAt > maxHeartbeatAgeMs) return invalid("后台服务健康心跳已过期");
|
|
53
|
+
if (updatedAt - now > 5_000) return invalid("后台服务健康时间戳来自未来");
|
|
54
|
+
if (!Array.isArray(health.consumers) || health.consumers.length < 2) {
|
|
55
|
+
return invalid("飞书事件消费者尚未完整启动");
|
|
56
|
+
}
|
|
57
|
+
if (health.consumers.some((consumer) => !consumer || consumer.ready !== true)) {
|
|
58
|
+
return invalid("至少一个飞书事件消费者尚未就绪");
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, reason: "服务健康且配置一致" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function waitForServiceHealth({
|
|
64
|
+
dataDir,
|
|
65
|
+
instanceId,
|
|
66
|
+
configFile,
|
|
67
|
+
timeoutMs = 30_000,
|
|
68
|
+
pollIntervalMs = 250,
|
|
69
|
+
processAlive,
|
|
70
|
+
}) {
|
|
71
|
+
const deadline = Date.now() + timeoutMs;
|
|
72
|
+
let lastReason = "尚未生成健康文件";
|
|
73
|
+
while (Date.now() <= deadline) {
|
|
74
|
+
try {
|
|
75
|
+
const health = await readServiceHealth(dataDir);
|
|
76
|
+
const validation = validateServiceHealth(health, {
|
|
77
|
+
instanceId,
|
|
78
|
+
configFile,
|
|
79
|
+
...(processAlive ? { processAlive } : {}),
|
|
80
|
+
});
|
|
81
|
+
if (validation.ok) return health;
|
|
82
|
+
lastReason = validation.reason;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error?.code !== "ENOENT") {
|
|
85
|
+
lastReason = error instanceof Error ? error.message : String(error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await delay(pollIntervalMs);
|
|
89
|
+
}
|
|
90
|
+
throw new Error(
|
|
91
|
+
`后台服务在 ${Math.ceil(timeoutMs / 1_000)} 秒内未就绪:${lastReason}。请查看服务日志后重新运行安装。`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function invalid(reason) {
|
|
96
|
+
return { ok: false, reason };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isProcessAlive(pid) {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, 0);
|
|
102
|
+
return true;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return error?.code === "EPERM";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function delay(milliseconds) {
|
|
109
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
110
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const action = process.argv[2] ?? "status";
|
|
7
|
+
const script =
|
|
8
|
+
process.platform === "darwin"
|
|
9
|
+
? path.join(scriptsDir, "launchd.mjs")
|
|
10
|
+
: process.platform === "linux"
|
|
11
|
+
? path.join(scriptsDir, "systemd.mjs")
|
|
12
|
+
: null;
|
|
13
|
+
|
|
14
|
+
if (!script) {
|
|
15
|
+
console.error("后台服务安装目前支持 macOS LaunchAgent 和 Linux systemd user service。");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const result = spawnSync(process.execPath, [script, action], {
|
|
20
|
+
cwd: path.resolve(scriptsDir, ".."),
|
|
21
|
+
env: process.env,
|
|
22
|
+
stdio: "inherit",
|
|
23
|
+
});
|
|
24
|
+
process.exitCode = result.status ?? 1;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const SETUP_PRESETS = Object.freeze({
|
|
5
|
+
personal: Object.freeze({
|
|
6
|
+
label: "个人安全(推荐)",
|
|
7
|
+
sandboxMode: "workspace-write",
|
|
8
|
+
operatorSandboxMode: "workspace-write",
|
|
9
|
+
networkAccess: false,
|
|
10
|
+
webSearchMode: "disabled",
|
|
11
|
+
}),
|
|
12
|
+
team: Object.freeze({
|
|
13
|
+
label: "团队安全",
|
|
14
|
+
sandboxMode: "workspace-write",
|
|
15
|
+
operatorSandboxMode: "workspace-write",
|
|
16
|
+
networkAccess: false,
|
|
17
|
+
webSearchMode: "disabled",
|
|
18
|
+
}),
|
|
19
|
+
power: Object.freeze({
|
|
20
|
+
label: "高级模式",
|
|
21
|
+
sandboxMode: "danger-full-access",
|
|
22
|
+
operatorSandboxMode: "workspace-write",
|
|
23
|
+
networkAccess: false,
|
|
24
|
+
webSearchMode: "disabled",
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export function normalizeInstanceId(value = "default") {
|
|
29
|
+
const normalized = value
|
|
30
|
+
.trim()
|
|
31
|
+
.toLocaleLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
33
|
+
.replace(/^-+|-+$/g, "")
|
|
34
|
+
.slice(0, 40);
|
|
35
|
+
if (!normalized) throw new Error("实例名称至少需要包含一个字母或数字。");
|
|
36
|
+
return normalized;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function splitCsv(value = "") {
|
|
40
|
+
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateOpenIds(value, label = "open_id") {
|
|
44
|
+
const ids = Array.isArray(value) ? value : splitCsv(value);
|
|
45
|
+
if (ids.length === 0) throw new Error(`${label} 不能为空。`);
|
|
46
|
+
for (const id of ids) {
|
|
47
|
+
if (!/^ou_[A-Za-z0-9_-]+$/.test(id)) {
|
|
48
|
+
throw new Error(`${label}“${id}”格式不正确,应以 ou_ 开头。`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return ids;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateChatIds(value, label = "chat_id") {
|
|
55
|
+
const ids = Array.isArray(value) ? value : splitCsv(value);
|
|
56
|
+
for (const id of ids) {
|
|
57
|
+
if (!/^oc_[A-Za-z0-9_-]+$/.test(id)) {
|
|
58
|
+
throw new Error(`${label}“${id}”格式不正确,应以 oc_ 开头。`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return ids;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function defaultSetupPaths(instanceId = "default", home = homedir()) {
|
|
65
|
+
const instance = normalizeInstanceId(instanceId);
|
|
66
|
+
return {
|
|
67
|
+
instance,
|
|
68
|
+
configDir: path.join(home, ".config", "feishu-codex-bridge"),
|
|
69
|
+
configFile: path.join(home, ".config", "feishu-codex-bridge", `${instance}.env`),
|
|
70
|
+
dataDir: path.join(home, ".local", "share", "feishu-codex-bridge", instance),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildEnvFile(options) {
|
|
75
|
+
const preset = SETUP_PRESETS[options.preset];
|
|
76
|
+
if (!preset) throw new Error(`未知权限预设:${options.preset}`);
|
|
77
|
+
const operators = validateOpenIds(options.operatorIds, "操作者 open_id");
|
|
78
|
+
const admins = validateOpenIds(options.adminIds?.length ? options.adminIds : operators, "管理员 open_id");
|
|
79
|
+
const viewers = splitCsv((options.viewerIds ?? []).join?.(",") ?? options.viewerIds ?? "");
|
|
80
|
+
if (viewers.length > 0) validateOpenIds(viewers, "只读成员 open_id");
|
|
81
|
+
const chats = splitCsv((options.chatIds ?? []).join?.(",") ?? options.chatIds ?? "");
|
|
82
|
+
validateChatIds(chats, "群聊 chat_id");
|
|
83
|
+
const workdir = path.resolve(options.workdir);
|
|
84
|
+
const roots = splitCsv((options.projectRoots ?? []).join?.(",") ?? options.projectRoots ?? "")
|
|
85
|
+
.map((root) => path.resolve(root));
|
|
86
|
+
const lines = [
|
|
87
|
+
"# Generated by feishu-codex-bridge init. Keep this file private.",
|
|
88
|
+
"BRIDGE_CONFIG_VERSION=1",
|
|
89
|
+
`BRIDGE_INSTANCE_ID=${envValue(options.instanceId)}`,
|
|
90
|
+
`BRIDGE_DATA_DIR=${envValue(path.resolve(options.dataDir))}`,
|
|
91
|
+
`ALLOWED_FEISHU_OPEN_IDS=${envValue(operators.join(","))}`,
|
|
92
|
+
`FEISHU_ADMIN_OPEN_IDS=${envValue(admins.join(","))}`,
|
|
93
|
+
`FEISHU_VIEWER_OPEN_IDS=${envValue(viewers.join(","))}`,
|
|
94
|
+
`FEISHU_MEMBER_LABELS_JSON=${envValue(options.memberLabelsJson ?? "")}`,
|
|
95
|
+
`ALLOWED_FEISHU_CHAT_IDS=${envValue(chats.join(","))}`,
|
|
96
|
+
"FEISHU_GROUP_SESSION_SCOPE=member",
|
|
97
|
+
`CODEX_WORKDIR=${envValue(workdir)}`,
|
|
98
|
+
"CODEX_SYNC_SAVED_PROJECTS=true",
|
|
99
|
+
`CODEX_PROJECT_ROOTS=${envValue(roots.join(","))}`,
|
|
100
|
+
`CODEX_SANDBOX_MODE=${preset.sandboxMode}`,
|
|
101
|
+
`CODEX_OPERATOR_SANDBOX_MODE=${preset.operatorSandboxMode}`,
|
|
102
|
+
`CODEX_NETWORK_ACCESS=${preset.networkAccess}`,
|
|
103
|
+
`CODEX_WEB_SEARCH_MODE=${preset.webSearchMode}`,
|
|
104
|
+
"CODEX_MULTI_AGENT_ENABLED=false",
|
|
105
|
+
"FEISHU_AUTO_ONBOARDING=true",
|
|
106
|
+
"FEISHU_COMPLETION_NOTIFICATIONS=false",
|
|
107
|
+
"LOG_MESSAGE_CONTENT=false",
|
|
108
|
+
"",
|
|
109
|
+
];
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function envValue(value) {
|
|
114
|
+
const text = String(value ?? "");
|
|
115
|
+
if (text === "") return "";
|
|
116
|
+
if (!/[\s#'"\\]/.test(text)) return text;
|
|
117
|
+
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { chmod, 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 unitName = `feishu-codex-bridge-${instanceId}.service`;
|
|
13
|
+
const unitDir = path.join(homedir(), ".config", "systemd", "user");
|
|
14
|
+
const unitPath = path.join(unitDir, unitName);
|
|
15
|
+
const action = process.argv[2] ?? "status";
|
|
16
|
+
|
|
17
|
+
if (process.platform !== "linux") {
|
|
18
|
+
console.error("systemd user service 仅支持 Linux。");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (action === "install") await install();
|
|
23
|
+
else if (action === "uninstall") await uninstall();
|
|
24
|
+
else if (action === "status") process.exitCode = systemctl(["status", unitName], true).status ?? 1;
|
|
25
|
+
else if (action === "stop") process.exitCode = systemctl(["stop", unitName], true).status ?? 1;
|
|
26
|
+
else if (action === "restart") process.exitCode = systemctl(["restart", unitName], true).status ?? 1;
|
|
27
|
+
else {
|
|
28
|
+
console.error("用法:node scripts/systemd.mjs install|uninstall|status|stop|restart");
|
|
29
|
+
process.exitCode = 2;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function install() {
|
|
33
|
+
await mkdir(unitDir, { recursive: true, mode: 0o700 });
|
|
34
|
+
await chmod(unitDir, 0o700);
|
|
35
|
+
await writeFile(unitPath, unit(), { encoding: "utf8", mode: 0o600 });
|
|
36
|
+
systemctl(["daemon-reload"], true);
|
|
37
|
+
const result = systemctl(["enable", "--now", unitName], true);
|
|
38
|
+
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
39
|
+
console.log(`已安装并启动 ${unitName}`);
|
|
40
|
+
console.log(`查看日志:journalctl --user -u ${unitName} -f`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function uninstall() {
|
|
44
|
+
systemctl(["disable", "--now", unitName], false);
|
|
45
|
+
await rm(unitPath, { force: true });
|
|
46
|
+
systemctl(["daemon-reload"], false);
|
|
47
|
+
console.log(`已卸载 ${unitName}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function systemctl(args, inherit) {
|
|
51
|
+
return spawnSync("systemctl", ["--user", ...args], {
|
|
52
|
+
cwd: projectDir,
|
|
53
|
+
env: process.env,
|
|
54
|
+
encoding: "utf8",
|
|
55
|
+
stdio: inherit ? "inherit" : "ignore",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unit() {
|
|
60
|
+
const environmentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
61
|
+
const configEnvironment = configFile
|
|
62
|
+
? `Environment=${systemdQuote(`DOTENV_CONFIG_PATH=${path.resolve(configFile)}`)}\n`
|
|
63
|
+
: "";
|
|
64
|
+
return `[Unit]
|
|
65
|
+
Description=Feishu Codex Bridge (${instanceId})
|
|
66
|
+
After=network-online.target
|
|
67
|
+
Wants=network-online.target
|
|
68
|
+
|
|
69
|
+
[Service]
|
|
70
|
+
Type=simple
|
|
71
|
+
WorkingDirectory=${systemdQuote(projectDir)}
|
|
72
|
+
ExecStart=${systemdQuote(process.execPath)} --disable-warning=ExperimentalWarning ${systemdQuote(path.join(projectDir, "dist", "index.js"))}
|
|
73
|
+
Environment=${systemdQuote(`PATH=${environmentPath}`)}
|
|
74
|
+
Environment=NODE_ENV=production
|
|
75
|
+
${configEnvironment}Restart=always
|
|
76
|
+
RestartSec=5
|
|
77
|
+
UMask=0077
|
|
78
|
+
|
|
79
|
+
[Install]
|
|
80
|
+
WantedBy=default.target
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function systemdQuote(value) {
|
|
85
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function safeInstanceId(value) {
|
|
89
|
+
const normalized = (value?.trim() || "default")
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
92
|
+
.replace(/^-+|-+$/g, "")
|
|
93
|
+
.slice(0, 40);
|
|
94
|
+
if (!normalized) throw new Error("BRIDGE_INSTANCE_ID must contain letters or numbers");
|
|
95
|
+
return normalized;
|
|
96
|
+
}
|