import assert from "node:assert/strict"; import { existsSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { clientConfig, runCli, setupAuthorizationEvent } from "../src/cli.js"; import { VERSION } from "../src/version.js"; test("package, manifest, and runtime versions agree with commit-pinned installation", () => { const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); const manifest = JSON.parse(readFileSync(new URL("../install-manifest.json", import.meta.url), "utf8")); assert.equal(VERSION, "0.4.0"); assert.equal(packageJson.version, VERSION); assert.equal(manifest.version, VERSION); assert.equal(manifest.schemaVersion, 3); assert.equal(manifest.distribution.branch, "main"); assert.equal(manifest.distribution.strategy, "resolve-latest-commit-then-pin"); assert.equal(manifest.distribution.releaseRequired, false); assert.equal(manifest.distribution.releaseRef, undefined); assert.equal(manifest.installation.temporarySourceArchiveBuild, true); assert.equal(manifest.installation.fallbackToRelease, false); assert.equal(manifest.installation.supportsInPlaceUpdate, true); assert.equal(manifest.installation.sameCommitBuildSkip, true); assert.deepEqual(manifest.installation.installReceipt, { path: "/install-receipt.json", schemaVersion: 1, posixFileMode: "0600", containsSecrets: false, }); assert.deepEqual(manifest.installation.operationDetection, [ "fresh-install", "in-place-update", "repair", "already-current", ]); assert.equal(manifest.entrypoints.guidedCommand, "client-config"); assert.equal(manifest.entrypoints.mcpLoginTool, "nocobase_login"); assert.equal(manifest.entrypoints.mcpLoginStatusTool, "nocobase_login_status"); assert.equal(manifest.authentication.processRestartResumable, true); assert.equal( manifest.authentication.pendingAuthorizationStorage, "short-lived per-user private local file", ); assert.equal(manifest.authentication.allowDeviceCodeExposure, false); assert.deepEqual(manifest.authentication.userVerificationDelivery, { fields: ["login_url", "verification_uri_complete", "user_code"], intendedRecipient: "requesting-user", channel: "current-private-conversation", shareableWithIntendedRecipient: true, publicSharingAllowed: false, containsBearerCredentials: false, containsDeviceCode: false, remoteDesktopRequired: false, }); assert.deepEqual(packageJson.dependencies, undefined); assert.equal(manifest.runtime.runtimeNpmDependencies, 0); }); test("installation and update protocol resolves main once and never falls back to an old Release", () => { const guide = readFileSync(new URL("../INSTALL.md", import.meta.url), "utf8"); const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8"); const installUrl = "https://git.justconn.top/gitea-admin/nocobase-mcp/raw/branch/main/INSTALL.md"; assert.match(guide, /branches\/main/); assert.match(guide, /40 位十六进制完整 SHA/); assert.match(guide, /resolve-latest-commit-then-pin/); assert.match(guide, /installation\.supportsInPlaceUpdate=true/); assert.match(guide, /installation\.sameCommitBuildSkip=true/); assert.match(guide, /archive\/\.zip/); assert.match(guide, /archive\/\.tar\.gz/); assert.match(guide, /Windows 使用 `.zip`/); assert.match(guide, /不得无条件调用.*TarFile\.extractall/); assert.match(guide, /inspect\.signature/); assert.match(guide, /extractfile\(\)/); assert.match(guide, /不得直接调用不带过滤器的 `extractall\(\)`/); assert.match(guide, /不需要重新解析 `main`、重新下载归档或中断用户/); assert.match(guide, /不得.*退回旧 Release/); assert.match(guide, /client-config \[--config \] \[--enable-writes\] --json/); assert.match(guide, /nocobase_login\(\{\}\)/); assert.match(guide, /stdio 进程.*退出或重启/); assert.match(guide, /下一次 `nocobase_login_status`.*恢复同一个链接/); assert.match(guide, /判断首次安装、更新或已有最新版/); assert.match(guide, /install-receipt\.json/); assert.match(guide, /保留.*CRM.*配置.*个人凭据.*只读\/写入模式/); assert.match(guide, /先调用 `crm_health`/); assert.match(guide, /不得仅因更新而要求用户重新登录/); assert.match(guide, /不得以“链接包含一次性凭据”为由拒绝发送/); assert.match(guide, /不得要求用户操作服务器远程桌面/); assert.ok(guide.includes(installUrl)); assert.ok(readme.includes(installUrl)); assert.match(readme, /帮我安装或更新 NocoBase CRM MCP/); assert.match(readme, /相同 Commit 不会重复构建/); assert.match(readme, /只有认证确实缺失或失效时才发起登录/); assert.match(readme, /无界面或远程环境也要发到当前私聊/); assert.match(readme, /不要要求远程桌面/); assert.match(readme, /nocobase_login/); const promptStart = readme.indexOf("请按以下 INSTALL.md"); const promptEnd = readme.indexOf(installUrl, promptStart) + installUrl.length; assert.ok(promptStart >= 0 && promptEnd > promptStart); assert.ok([...readme.slice(promptStart, promptEnd)].length < 350); assert.doesNotMatch(guide, /下载本文件所在的同一固定 Release/); assert.doesNotMatch(readme, /
/); }); test("headless setup emits user-deliverable OAuth verification metadata", () => { const event = setupAuthorizationEvent({ opened: false, url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE", userCode: "TEST-CODE", expiresIn: 600, }); assert.equal(event.event, "authorization_required"); assert.equal(event.authorization_type, "oauth_device_user_verification"); assert.equal(event.login_url, "https://justconn.top/idpOAuth/device?user_code=TEST-CODE"); assert.equal(event.verification_uri_complete, event.login_url); assert.equal(event.user_code, "TEST-CODE"); assert.equal(event.intended_recipient, "requesting_user"); assert.equal(event.delivery_channel, "current_private_conversation"); assert.equal(event.shareable_with_requesting_user, true); assert.equal(event.private_conversation_required, true); assert.equal(event.public_sharing_allowed, false); assert.equal(event.contains_bearer_credentials, false); assert.equal(event.contains_device_code, false); assert.equal(event.remote_desktop_required, false); assert.match(String(event.next_action), /current private conversation/); assert.match(String(event.next_action), /remote desktop is not required/); assert.equal(Object.hasOwn(event, "access_token"), false); assert.equal(Object.hasOwn(event, "refresh_token"), false); assert.equal(Object.hasOwn(event, "device_code"), false); }); test("generated MCP config contains no Key, token, or environment secret", () => { const config = clientConfig("/private/config.json", false); const serialized = JSON.stringify(config); assert.match(serialized, /nocobase-crm/); assert.match(serialized, /config\.json/); assert.doesNotMatch(serialized, /authorize|token|secret|password/i); }); test("manual Key fallback refuses a non-TTY before creating credential files", async () => { if (process.stdin.isTTY || process.stderr.isTTY) return; const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-nontty-")); const path = join(directory, "credentials.json"); const previous = process.env.NOCOBASE_MCP_CREDENTIALS; process.env.NOCOBASE_MCP_CREDENTIALS = path; try { await assert.rejects(runCli(["setup", "--manual-key", "--json"]), /local terminal/); assert.equal(existsSync(path), false); assert.equal(existsSync(join(directory, "config.json")), false); } finally { if (previous === undefined) delete process.env.NOCOBASE_MCP_CREDENTIALS; else process.env.NOCOBASE_MCP_CREDENTIALS = previous; } }); test("setup rejects a missing explicit config before starting browser authorization", async () => { const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-setup-preflight-")); const credentials = join(directory, "credentials.json"); const missingConfig = join(directory, "missing-config.json"); const previousCredentials = process.env.NOCOBASE_MCP_CREDENTIALS; process.env.NOCOBASE_MCP_CREDENTIALS = credentials; try { await assert.rejects( runCli(["setup", "--config", missingConfig, "--no-browser", "--json"]), /Config file does not exist/, ); assert.equal(existsSync(credentials), false); } finally { if (previousCredentials === undefined) delete process.env.NOCOBASE_MCP_CREDENTIALS; else process.env.NOCOBASE_MCP_CREDENTIALS = previousCredentials; } }); test("client-config prepares the bundled default config before login", async () => { const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-client-config-")); const credentials = join(directory, "credentials.json"); const config = join(directory, "config.json"); const previousCredentials = process.env.NOCOBASE_MCP_CREDENTIALS; const previousConfig = process.env.NOCOBASE_MCP_CONFIG; const originalWrite = process.stdout.write; let output = ""; process.env.NOCOBASE_MCP_CREDENTIALS = credentials; delete process.env.NOCOBASE_MCP_CONFIG; process.stdout.write = ((chunk: string | Uint8Array) => { output += String(chunk); return true; }) as typeof process.stdout.write; try { await runCli(["client-config", "--json"]); assert.equal(existsSync(config), true); assert.equal(existsSync(credentials), false); assert.equal(JSON.parse(output).mcpServers["nocobase-crm"].args.includes("--config"), true); } finally { process.stdout.write = originalWrite; if (previousCredentials === undefined) delete process.env.NOCOBASE_MCP_CREDENTIALS; else process.env.NOCOBASE_MCP_CREDENTIALS = previousCredentials; if (previousConfig === undefined) delete process.env.NOCOBASE_MCP_CONFIG; else process.env.NOCOBASE_MCP_CONFIG = previousConfig; } }); test("setup rejects API endpoint overrides", async () => { await assert.rejects( runCli(["setup", "--api-base-url", "https://other.example"]), /Unknown option: --api-base-url/, ); });