#!/usr/bin/env node
* Build the DeepSeek Harness bridge into the profile directory the desktop
* bundle ships (`packages/dsh-acp/dist-profile`).
*
* BitFun launches DeepSeek Harness as `dsh --profile bitfun-acp`, and copies
* that directory into the user's own dsh install on first use — see
* `src/crates/interfaces/acp/src/client/dsh_profile.rs`. The build has to
* happen here rather than in Cargo because it is a TypeScript compile.
*
* `packages/dsh-acp` is deliberately NOT a pnpm workspace member: it pins the
* whole harness `0.1.0-rc.6` train, and making every contributor's
* `pnpm install` fetch that is a poor trade when only the desktop bundle needs
* it. So this installs the package from its own lockfile, here, once — unless
* a developer already linked a local harness checkout, in which case that
* checkout wins and no install runs.
*
* A failure here fails the build. An app that silently ships no bridge looks
* exactly like an app that ships a working one until a user tries to start a
* DeepSeek session, and that is the wrong place to find out. Set
* `BITFUN_SKIP_DSH_PROFILE=1` to opt out deliberately.
*
* Official `desktop:build` compiles this via `frontend:build-all`. desktop:dev
* and cargo check do not: the profile is not a compile-time Tauri resource.
* Local DeepSeek sessions run this script explicitly. A stamped profile whose
* inputs have not changed is left alone — the same mtime short-circuit
* mobile-web uses. Escape hatches: `--force` /
* `BITFUN_DSH_PROFILE_FORCE_BUILD=1`.
*/
import { spawnSync } from 'node:child_process';
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import path, { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PACKAGE_DIR = path.join(ROOT_DIR, 'packages', 'dsh-acp');
const OUT_DIR = path.join(PACKAGE_DIR, 'dist-profile');
const OHOS_OUT_DIR = path.join(
ROOT_DIR,
'src',
'apps',
'ohos',
'entry',
'src',
'main',
'resources',
'resfile',
'dsh-profile',
);
const STAMP_FILENAME = '.bitfun-bridge.json';
const OHOS_STAMP_FILENAME = 'bitfun-bridge.json';
const NODE_MODULES_DIR = 'node_modules';
const OHOS_NODE_MODULES_DIR = 'vendor-node-modules';
const DEPS_PROBE = path.join(PACKAGE_DIR, 'node_modules', '@deepseek-ai', 'dsh-app-boot');
const INPUT_IGNORED_DIRS = new Set([
'node_modules',
'lib',
'dist-profile',
'.sessions',
]);
const PLACEHOLDER = `This BitFun build ships no DeepSeek Harness bridge.
BITFUN_SKIP_DSH_PROFILE was set when this build ran, so packages/dsh-acp was
never compiled. BitFun's profile resolver rejects this unstamped directory and
reports that the bridge is missing from the build.
To include it: run \`pnpm run prepare:dsh-profile\` without that variable.
`;
* Newest mtime among the profile build inputs (recursively for directories).
* @param {string} entryPath
* @returns {{ path: string, mtimeMs: number } | null}
*/
export function getNewestInputMtime(entryPath) {
if (!existsSync(entryPath)) {
return null;
}
const stat = lstatSync(entryPath);
if (stat.isSymbolicLink()) {
return null;
}
if (stat.isFile()) {
return { path: entryPath, mtimeMs: stat.mtimeMs };
}
if (!stat.isDirectory()) {
return null;
}
let newest = null;
for (const entry of readdirSync(entryPath, { withFileTypes: true })) {
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory() && INPUT_IGNORED_DIRS.has(entry.name)) {
continue;
}
const candidate = getNewestInputMtime(path.join(entryPath, entry.name));
if (candidate && (!newest || candidate.mtimeMs > newest.mtimeMs)) {
newest = candidate;
}
}
return newest;
}
* Decide whether the shipped profile needs a rebuild.
*
* A directory is not enough: Tauri only needs the path to exist, but BitFun's
* resolver rejects an unstamped tree. The stamp `.bitfun-bridge.json` is the
* same file `scripts/build-profile.mjs` writes and ACP reads.
*
* @param {{
* packageDir?: string,
* outDir?: string,
* prepareScriptPath?: string,
* force?: boolean,
* }} [options]
* @returns {{ shouldBuild: boolean, reason: string }}
*/
export function getDshProfileRebuildPlan({
packageDir = PACKAGE_DIR,
outDir = path.join(packageDir, 'dist-profile'),
prepareScriptPath = path.join(ROOT_DIR, 'scripts', 'prepare-dsh-profile.mjs'),
force = false,
} = {}) {
if (force) {
return { shouldBuild: true, reason: 'Force rebuild requested for dsh-profile' };
}
const stampPath = path.join(outDir, STAMP_FILENAME);
if (!existsSync(stampPath)) {
return { shouldBuild: true, reason: 'dsh-profile stamp is missing' };
}
try {
const stamp = JSON.parse(readFileSync(stampPath, 'utf8'));
if (
typeof stamp.profile !== 'string' ||
stamp.profile === '' ||
typeof stamp.content !== 'string' ||
stamp.content === ''
) {
return { shouldBuild: true, reason: 'dsh-profile stamp is incomplete' };
}
} catch {
return { shouldBuild: true, reason: 'dsh-profile stamp is unreadable' };
}
const stampMtimeMs = statSync(stampPath).mtimeMs;
const inputs = [
path.join(packageDir, 'src'),
path.join(packageDir, 'presets'),
path.join(packageDir, 'cordis.yml'),
path.join(packageDir, 'package.json'),
path.join(packageDir, 'package-lock.json'),
path.join(packageDir, 'tsconfig.build.json'),
path.join(packageDir, 'scripts', 'build-profile.mjs'),
prepareScriptPath,
];
let newestInput = null;
for (const input of inputs) {
const candidate = getNewestInputMtime(input);
if (candidate && (!newestInput || candidate.mtimeMs > newestInput.mtimeMs)) {
newestInput = candidate;
}
}
if (newestInput && newestInput.mtimeMs > stampMtimeMs) {
return {
shouldBuild: true,
reason: `dsh-profile inputs changed since the last build (${path.relative(ROOT_DIR, newestInput.path)})`,
};
}
return {
shouldBuild: false,
reason:
'dsh-profile is up to date; skipping install/build (use --force or BITFUN_DSH_PROFILE_FORCE_BUILD=1 to rebuild)',
};
}
* Stage the built profile into HarmonyOS resfile-compatible names.
*
* The HarmonyOS resource bundle uses aliases for the profile's leading-dot
* marker and `node_modules` directory. Keep the canonical desktop build
* unchanged and normalize the names back when the Rust client materializes the
* profile into the user's DSH home.
*
* @param {{ sourceDir?: string, destinationDir?: string }} [options]
*/
export function syncOhosDshProfile({
sourceDir = OUT_DIR,
destinationDir = OHOS_OUT_DIR,
} = {}) {
rmSync(destinationDir, { recursive: true, force: true });
mkdirSync(path.dirname(destinationDir), { recursive: true });
cpSync(sourceDir, destinationDir, { recursive: true, dereference: true });
const stamp = path.join(destinationDir, STAMP_FILENAME);
if (existsSync(stamp)) {
renameSync(stamp, path.join(destinationDir, OHOS_STAMP_FILENAME));
}
const nodeModules = path.join(destinationDir, NODE_MODULES_DIR);
if (existsSync(nodeModules)) {
renameSync(nodeModules, path.join(destinationDir, OHOS_NODE_MODULES_DIR));
}
}
* Run a command in the bridge package directory.
* @param {string} command - the executable to run.
* @param {string[]} args - its arguments.
* @returns {number} the exit status, with a missing status treated as a failure.
*/
function run(command, args) {
const result = spawnSync(command, args, {
cwd: PACKAGE_DIR,
shell: process.platform === 'win32',
stdio: 'inherit',
env: process.env,
});
return result.status ?? 1;
}
* Fail the build, saying what to do about it.
* @param {string} message - what went wrong.
* @param {number} status - the exit status to propagate.
*/
function fail(message, status) {
process.stderr.write(`[dsh-profile] ${message}\n`);
process.stderr.write(
'[dsh-profile] set BITFUN_SKIP_DSH_PROFILE=1 to build without the DeepSeek bridge\n',
);
process.exit(status === 0 ? 1 : status);
}
function main() {
if (process.env.BITFUN_SKIP_DSH_PROFILE === '1') {
process.stdout.write('[dsh-profile] skipped (this build ships no DeepSeek bridge)\n');
rmSync(OUT_DIR, { recursive: true, force: true });
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(path.join(OUT_DIR, 'NOT-BUILT.md'), PLACEHOLDER);
syncOhosDshProfile();
return;
}
const force =
process.argv.includes('--force') || process.env.BITFUN_DSH_PROFILE_FORCE_BUILD === '1';
const plan = getDshProfileRebuildPlan({ force });
if (!plan.shouldBuild) {
process.stdout.write(`[dsh-profile] ${plan.reason}\n`);
syncOhosDshProfile();
return;
}
process.stdout.write(`[dsh-profile] ${plan.reason}\n`);
if (!existsSync(DEPS_PROBE)) {
process.stdout.write('[dsh-profile] installing the harness toolchain from package-lock.json\n');
const installed = run('npm', ['ci', '--no-audit', '--no-fund']);
if (installed !== 0) fail('npm ci failed', installed);
}
const compiled = run('npm', ['run', 'build']);
if (compiled !== 0) fail('tsc failed', compiled);
const packaged = run('node', ['scripts/build-profile.mjs']);
if (packaged !== 0) fail('profile packaging failed', packaged);
syncOhosDshProfile();
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}