import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
configureDeploymentInteractively,
collectManagedPorts,
createDeployLogWriter,
inspectWritablePath,
resolveAuthPublicBaseUrl,
resolveTuiRuntimeUrl,
runDeploymentDoctor
} from "./cli.mjs";
function createAsk(answers) {
const queue = [...answers];
return async () => {
if (queue.length === 0) throw new Error("unexpected prompt");
return queue.shift();
};
}
test("first run selects Web and API ports with defaults", async () => {
const lines = [];
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
const result = await configureDeploymentInteractively({
root,
sourceText: "",
reconfigure: false,
nonInteractive: false,
ask: createAsk(["1", "1", ""]),
print: (line) => lines.push(String(line)),
probe: async () => ({ available: true, owner: null })
});
assert.equal(result.env.WEB_PORT, "3000");
assert.equal(result.env.API_PORT, "8787");
});
test("existing complete config skips prompts unless reconfigure", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
const source = [
"WEB_PORT=3310",
"API_PORT=8877",
"AUTH_SESSION_SECRET=existing-session-secret-value",
"SECRET_MASTER_KEY=existing-master-secret-value",
"AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3310",
"AUTH_REGISTRATION_MODE=open",
"AUTH_EMAIL_DELIVERY=test",
"WEB_HOST=127.0.0.1",
"API_HOST=127.0.0.1",
"STORAGE_ROOT_DIR=storage",
"METADATA_DB_PATH=storage/metadata/workbench.sqlite"
].join("\n");
let asked = 0;
const result = await configureDeploymentInteractively({
root,
sourceText: source,
reconfigure: false,
nonInteractive: false,
ask: async () => {
asked += 1;
throw new Error("must not prompt");
},
print: () => {},
probe: async () => ({ available: true, owner: null })
});
assert.equal(asked, 0);
assert.equal(result.env.WEB_PORT, "3310");
});
test("legacy complete env missing AUTH_REGISTRATION_MODE fills default and skips prompts", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-legacy-reg-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
const source = [
"WEB_PORT=3310",
"API_PORT=8877",
"AUTH_SESSION_SECRET=existing-session-secret-value",
"SECRET_MASTER_KEY=existing-master-secret-value",
"AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3310",
"DATAFOUNDRY_AUTH_MODE=password",
"AUTH_EMAIL_DELIVERY=test",
"WEB_HOST=0.0.0.0",
"API_HOST=127.0.0.1",
"STORAGE_ROOT_DIR=storage",
"METADATA_DB_PATH=storage/metadata/workbench.sqlite"
].join("\n");
let asked = 0;
const result = await configureDeploymentInteractively({
root,
sourceText: source,
reconfigure: false,
nonInteractive: false,
ask: async () => {
asked += 1;
throw new Error("must not prompt");
},
print: () => {},
probe: async () => ({ available: true, owner: null })
});
assert.equal(asked, 0);
assert.equal(result.env.WEB_PORT, "3310");
assert.equal(result.env.AUTH_REGISTRATION_MODE, "open");
assert.match(result.envText, /^AUTH_REGISTRATION_MODE=open$/m);
assert.equal(result.env.DATAFOUNDRY_AUTH_MODE, undefined);
assert.doesNotMatch(result.envText, /DATAFOUNDRY_AUTH_MODE/);
});
test("partial .env is not treated as complete before fill and still prompts", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-partial-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
let asked = 0;
const result = await configureDeploymentInteractively({
root,
sourceText: "FOO=bar\nCUSTOM=keep-me\n",
reconfigure: false,
nonInteractive: false,
ask: createAsk(["1", "1", ""]),
print: () => {
asked += 1;
},
probe: async () => ({ available: true, owner: null })
});
assert.match(result.envText, /CUSTOM=keep-me/);
assert.ok(asked > 0);
});
test("non-interactive never calls ask and uses loopback defaults", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
const result = await configureDeploymentInteractively({
root,
sourceText: "",
reconfigure: false,
nonInteractive: true,
ask: async () => assert.fail("must not prompt"),
print: () => {},
probe: async () => ({ available: true, owner: null })
});
assert.equal(result.env.WEB_PORT, "3000");
assert.equal(result.env.WEB_HOST, "127.0.0.1");
assert.equal(result.env.API_HOST, "127.0.0.1");
assert.equal(result.env.AUTH_PUBLIC_BASE_URL, "http://127.0.0.1:3000");
assert.equal(result.env.DATAFOUNDRY_AUTH_MODE, undefined);
assert.equal(result.env.DATALINK_ENABLED, undefined);
});
test("non-interactive rejects wildcard bind hosts", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
await assert.rejects(
() =>
configureDeploymentInteractively({
root,
sourceText: "",
reconfigure: false,
nonInteractive: true,
processEnv: { WEB_HOST: "0.0.0.0" },
ask: async () => assert.fail("must not prompt"),
print: () => {},
probe: async () => ({ available: true, owner: null })
}),
/WEB_HOST|loopback|SSH|TLS/i
);
});
test("port menu rejects n with explicit hint", async () => {
const lines = [];
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
const ask = createAsk(["n", "1", "1", ""]);
await configureDeploymentInteractively({
root,
sourceText: "",
reconfigure: false,
nonInteractive: false,
ask,
print: (line) => lines.push(String(line)),
probe: async () => ({ available: true, owner: null })
});
assert.match(lines.join("\n"), /请输入 1 或 2/);
});
test("port menu aborts after too many invalid choices", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
await assert.rejects(
() =>
configureDeploymentInteractively({
root,
sourceText: "",
reconfigure: false,
nonInteractive: false,
ask: createAsk(["n", "n", "n", "n", "n"]),
print: () => {},
probe: async () => ({ available: true, owner: null })
}),
/too many invalid choices/
);
});
test("reconfigure creates backup and keeps secrets", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
const source = [
"WEB_PORT=3000",
"API_PORT=8787",
"AUTH_SESSION_SECRET=existing-session-secret-value",
"SECRET_MASTER_KEY=existing-master-secret-value",
"AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000",
"AUTH_EMAIL_DELIVERY=test",
"WEB_HOST=127.0.0.1",
"API_HOST=127.0.0.1",
"STORAGE_ROOT_DIR=storage",
"METADATA_DB_PATH=storage/metadata/workbench.sqlite"
].join("\n");
await writeFile(path.join(root, ".env"), `${source}\n`);
const result = await configureDeploymentInteractively({
root,
sourceText: source,
reconfigure: true,
nonInteractive: false,
ask: createAsk(["1", "1", ""]),
print: () => {},
probe: async () => ({ available: true, owner: null }),
write: true,
timestamp: "20260722-150000"
});
assert.match(result.envText, /AUTH_SESSION_SECRET=existing-session-secret-value/);
assert.equal(
await readFile(path.join(root, ".env.backup-20260722-150000"), "utf8"),
`${source}\n`
);
});
test("collectManagedPorts ignores bare .env ports without a verified running state", async () => {
const bare = await collectManagedPorts(
{ WEB_PORT: "3310", API_PORT: "8877" },
null
);
assert.deepEqual([...bare], []);
const deadPid = await collectManagedPorts(
{ WEB_PORT: "3310", API_PORT: "8877" },
{
pid: 99999999,
launchId: "launch-1",
ports: { web: 3310, api: 8877 }
}
);
assert.deepEqual([...deadPid], []);
});
test("collectManagedPorts marks state ports only when pid is alive and launchId verifies", async () => {
const unmanaged = await collectManagedPorts(
{ WEB_PORT: "3310", API_PORT: "8877" },
{
pid: process.pid,
launchId: "expected-launch",
ports: { web: 3310, api: 8877 }
},
{
readLaunchId: async () => "other-launch",
forceLaunchIdCheck: true
}
);
assert.deepEqual([...unmanaged], []);
const managed = await collectManagedPorts(
{ WEB_PORT: "3000", API_PORT: "8787" },
{
pid: process.pid,
launchId: "launch-1",
ports: { web: 3310, api: 8877 }
},
{
readLaunchId: async () => "launch-1",
forceLaunchIdCheck: true
}
);
assert.deepEqual([...managed].sort((a, b) => a - b), [3310, 8877]);
});
test("non-interactive fails when ports are occupied without a running managed state", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-occupied-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
const source = [
"WEB_PORT=3310",
"API_PORT=8877",
"AUTH_SESSION_SECRET=existing-session-secret-value",
"SECRET_MASTER_KEY=existing-master-secret-value",
"AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3310",
"DATAFOUNDRY_AUTH_MODE=password",
"AUTH_EMAIL_DELIVERY=test",
"WEB_HOST=0.0.0.0",
"API_HOST=127.0.0.1",
"STORAGE_ROOT_DIR=storage",
"METADATA_DB_PATH=storage/metadata/workbench.sqlite"
].join("\n");
await assert.rejects(
() =>
configureDeploymentInteractively({
root,
sourceText: source,
reconfigure: false,
nonInteractive: true,
ask: async () => assert.fail("must not prompt"),
print: () => {},
probe: async () => ({ available: false, owner: "nginx pid=42" })
}),
/already in use/i
);
});
test("reconfigure reuses managed listening ports only for a verified running stack", async () => {
const probes = [];
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
await mkdir(path.join(root, "storage/run"), { recursive: true });
const source = [
"WEB_PORT=3310",
"API_PORT=8877",
"AUTH_SESSION_SECRET=existing-session-secret-value",
"SECRET_MASTER_KEY=existing-master-secret-value",
"AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3310",
"AUTH_EMAIL_DELIVERY=test",
"WEB_HOST=127.0.0.1",
"API_HOST=127.0.0.1",
"STORAGE_ROOT_DIR=storage",
"METADATA_DB_PATH=storage/metadata/workbench.sqlite"
].join("\n");
await writeFile(
path.join(root, "storage/run/deployment.json"),
`${JSON.stringify({
pid: process.pid,
launchId: "launch-managed",
status: "healthy",
startedAt: "2026-07-22T00:00:00.000Z",
ports: { web: 3310, api: 8877 }
})}\n`
);
const result = await configureDeploymentInteractively({
root,
sourceText: source,
reconfigure: true,
nonInteractive: true,
ask: async () => assert.fail("must not prompt"),
print: () => {},
probe: async (port) => {
probes.push(port);
return { available: false, owner: `datafoundry pid=${process.pid}` };
},
collectManagedPortsOptions: {
readLaunchId: async () => "launch-managed",
forceLaunchIdCheck: true
}
});
assert.equal(result.env.WEB_PORT, "3310");
assert.equal(result.env.API_PORT, "8877");
assert.ok(probes.includes(3310));
assert.ok(probes.includes(8877));
});
test("process env does not silently overwrite existing non-empty secrets", async () => {
const { overlayProcessEnv } = await import("./cli.mjs");
const source = [
"WEB_PORT=3000",
"API_PORT=8787",
"AUTH_SESSION_SECRET=disk-session-secret-value-32chars",
"SECRET_MASTER_KEY=disk-master-secret-value-32charsxx",
"AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000"
].join("\n");
const overlaid = overlayProcessEnv(source, {
WEB_PORT: "3310",
AUTH_SESSION_SECRET: "process-session-secret-should-not-win",
SECRET_MASTER_KEY: "process-master-secret-should-not-win"
});
const env = (await import("./config.mjs")).parseDeploymentEnvironment(overlaid);
assert.equal(env.WEB_PORT, "3310");
assert.equal(env.AUTH_SESSION_SECRET, "disk-session-secret-value-32chars");
assert.equal(env.SECRET_MASTER_KEY, "disk-master-secret-value-32charsxx");
});
test("process env may inject secrets only when disk is empty/placeholder or explicitly allowed", async () => {
const { overlayProcessEnv } = await import("./cli.mjs");
const { parseDeploymentEnvironment } = await import("./config.mjs");
const fromPlaceholder = overlayProcessEnv(
"AUTH_SESSION_SECRET=change-me\nSECRET_MASTER_KEY=\n",
{
AUTH_SESSION_SECRET: "ci-session-secret-value-at-least-32",
SECRET_MASTER_KEY: "ci-master-secret-value-at-least-32x"
}
);
const placeholderEnv = parseDeploymentEnvironment(fromPlaceholder);
assert.equal(placeholderEnv.AUTH_SESSION_SECRET, "ci-session-secret-value-at-least-32");
assert.equal(placeholderEnv.SECRET_MASTER_KEY, "ci-master-secret-value-at-least-32x");
const explicit = overlayProcessEnv(
"AUTH_SESSION_SECRET=disk-session-secret-value-32chars\nSECRET_MASTER_KEY=disk-master-secret-value-32charsxx\n",
{
AUTH_SESSION_SECRET: "ci-session-secret-value-at-least-32",
SECRET_MASTER_KEY: "ci-master-secret-value-at-least-32x",
DATAFOUNDRY_ALLOW_PROCESS_SECRET_OVERLAY: "1"
},
{ allowProcessSecretOverlay: true }
);
const explicitEnv = parseDeploymentEnvironment(explicit);
assert.equal(explicitEnv.AUTH_SESSION_SECRET, "ci-session-secret-value-at-least-32");
assert.equal(explicitEnv.SECRET_MASTER_KEY, "ci-master-secret-value-at-least-32x");
});
test("resolveAuthPublicBaseUrl preserves HTTPS proxy URLs without explicit port", () => {
assert.equal(
resolveAuthPublicBaseUrl("https://prod.example.com", "3000", "3001"),
"https://prod.example.com"
);
assert.equal(
resolveAuthPublicBaseUrl("http://127.0.0.1:3000", "3000", "3001"),
"http://127.0.0.1:3001"
);
assert.equal(
resolveAuthPublicBaseUrl("https://prod.example.com:8443", "8443", "3001"),
"https://prod.example.com:8443"
);
assert.equal(
resolveAuthPublicBaseUrl("https://prod.example.com:3000", "3000", "3001"),
"https://prod.example.com:3000"
);
assert.equal(
resolveAuthPublicBaseUrl("http://example.com:3000", "3000", "3310"),
"http://example.com:3000"
);
});
test("reconfigure keeps HTTPS public URL when ports are unchanged", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-"));
await mkdir(path.join(root, "apps/web"), { recursive: true });
const source = [
"WEB_PORT=3000",
"API_PORT=8787",
"AUTH_SESSION_SECRET=existing-session-secret-value",
"SECRET_MASTER_KEY=existing-master-secret-value",
"AUTH_PUBLIC_BASE_URL=https://prod.example.com",
"AUTH_EMAIL_DELIVERY=test",
"WEB_HOST=127.0.0.1",
"API_HOST=127.0.0.1",
"STORAGE_ROOT_DIR=storage",
"METADATA_DB_PATH=storage/metadata/workbench.sqlite"
].join("\n");
const result = await configureDeploymentInteractively({
root,
sourceText: source,
reconfigure: true,
nonInteractive: true,
ask: async () => assert.fail("must not prompt"),
print: () => {},
probe: async () => ({ available: true, owner: null })
});
assert.equal(result.env.AUTH_PUBLIC_BASE_URL, "https://prod.example.com");
});
test("createDeployLogWriter redacts secrets and updates deploy-latest.log", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-log-"));
const writer = await createDeployLogWriter(root, { timestamp: "20260722143000" });
await writer.append("AUTH_SESSION_SECRET=fixture-deploy-secret-at-least-32-chars\nnpm ci output\n");
await writer.finalize();
const logText = await readFile(path.join(root, "storage/logs/deploy-20260722143000.log"), "utf8");
const latestText = await readFile(path.join(root, "storage/logs/deploy-latest.log"), "utf8");
assert.doesNotMatch(logText, /fixture-deploy-secret-at-least-32-chars/);
assert.match(logText, /npm ci output/);
assert.equal(latestText, logText);
assert.equal(writer.logPath, "storage/logs/deploy-20260722143000.log");
});
test("createDeployLogWriter redacts secrets split across append chunks", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-log-chunk-"));
const writer = await createDeployLogWriter(root, { timestamp: "20260722143001" });
await writer.append("AUTH_SESSION_SECRET=fixture-deploy-secret-");
await writer.append("at-least-32-chars\nnpm ci output\n");
await writer.finalize();
const logText = await readFile(path.join(root, "storage/logs/deploy-20260722143001.log"), "utf8");
assert.doesNotMatch(logText, /fixture-deploy-secret-at-least-32-chars/);
assert.match(logText, /AUTH_SESSION_SECRET=\*{4,8}/);
assert.match(logText, /npm ci output/);
});
test("runDeploymentDoctor reports os, deps, config, ports, permissions, disk, pid, and health", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-doctor-"));
await mkdir(path.join(root, "storage"), { recursive: true });
await writeFile(
path.join(root, ".env"),
[
"WEB_PORT=3000",
"API_PORT=8787",
"AUTH_SESSION_SECRET=existing-session-secret-value",
"SECRET_MASTER_KEY=existing-master-secret-value",
"AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000"
].join("\n")
);
const beforeEntries = await readdir(path.join(root, "storage"));
const lines = [];
const result = await runDeploymentDoctor(root, {
print: (line) => lines.push(line),
run: async (command, args = []) => {
const key = [command, ...args].join(" ");
if (key === "node --version") return { stdout: "v22.14.0\n" };
if (key === "npm --version") return { stdout: "10.9.0\n" };
throw new Error(`missing mock for ${key}`);
},
probe: async () => ({ available: true, owner: null })
});
const joined = lines.join("\n");
assert.match(joined, /^os: /m);
assert.match(joined, /^arch: /m);
assert.match(joined, /dependency node: ok/);
assert.match(joined, /config: ok/);
assert.match(joined, /registration=open/);
assert.match(joined, /port web 3000: available/);
assert.match(joined, /permissions storage: writable/);
assert.match(joined, /disk free:/);
assert.match(joined, /pid: none/);
assert.match(joined, /health: skipped/);
assert.ok(result.lines.length >= 8);
const afterEntries = await readdir(path.join(root, "storage"));
assert.deepEqual(afterEntries, beforeEntries);
assert.ok(afterEntries.every((entry) => !entry.startsWith(".doctor-write-")));
});
test("inspectWritablePath checks access without creating files", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "df-writable-"));
const missingChild = path.join(root, "storage", "nested");
const result = await inspectWritablePath(missingChild);
assert.equal(result.writable, true);
assert.equal(result.exists, false);
assert.equal(existsSync(missingChild), false);
assert.equal(existsSync(path.join(root, "storage")), false);
});
test("runDeploymentDoctor reports actionable fix for stale launchId state", async (t) => {
if (process.platform !== "linux") {
t.skip("proc launchId verification is Linux-only");
return;
}
const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-doctor-stale-"));
await mkdir(path.join(root, "storage/run"), { recursive: true });
await writeFile(
path.join(root, ".env"),
[
"WEB_PORT=3000",
"API_PORT=8787",
"AUTH_SESSION_SECRET=existing-session-secret-value",
"SECRET_MASTER_KEY=existing-master-secret-value",
"AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000"
].join("\n")
);
await writeFile(
path.join(root, "storage/run/deployment.json"),
`${JSON.stringify({
pid: process.pid,
launchId: "not-this-process",
status: "healthy",
startedAt: "2026-07-26T00:00:00.000Z",
ports: { web: 3000, api: 8787 }
})}\n`
);
const lines = [];
await runDeploymentDoctor(root, {
print: (line) => lines.push(line),
run: async (command, args = []) => {
const key = [command, ...args].join(" ");
if (key === "node --version") return { stdout: "v22.14.0\n" };
if (key === "npm --version") return { stdout: "10.9.0\n" };
throw new Error(`missing mock for ${key}`);
},
probe: async () => ({ available: true, owner: null })
});
const joined = lines.join("\n");
assert.match(joined, /stale/i);
assert.match(joined, /\.\/deploy\.sh stop/);
assert.match(joined, /permissions \.env: present mode=/);
});
test("resolveTuiRuntimeUrl defaults to deployed API port", () => {
assert.equal(
resolveTuiRuntimeUrl({ API_PORT: "8877" }),
"http://127.0.0.1:8877/api/copilotkit"
);
assert.equal(
resolveTuiRuntimeUrl({ API_PORT: "8877" }, "http://example/api/copilotkit"),
"http://example/api/copilotkit"
);
});