Files
u-claw/portable/lib/bootstrap-xiapan.mjs
hfshfg cc2984234b feat(v2.1.2): align with ClawX schema + config-server port fallback
Three changes for v2.1.2:

1. bootstrap-xiapan now writes "agents.defaults.model.primary/fallbacks"
   (uclaw-cloud/deepseek-v4-flash + 3 fallbacks) and an empty models[],
   matching the ClawX commercial schema. Lets OpenClaw runtime auto-discover
   models from /v1/models instead of hard-coding a list that drifts.

2. config-server tries ports 18788..18798 with EADDRINUSE fallback and
   persists the live port to data/.openclaw/runtime.json so future tooling
   can discover it. Fixes silent failure when a second portable instance
   started while 18788 was occupied.

3. u-claw-app/package.json version 2.0.0 -> 2.1.2 so .exe/.dmg filenames
   align with the release tag.
2026-05-03 00:07:09 +08:00

186 lines
6.2 KiB
JavaScript

// Bootstrap: ensure data/.openclaw/openclaw.json contains the uclaw-cloud provider
// pointing to the device-bound apiKey derived from the local fingerprint.
//
// Idempotent: if the provider already exists with the correct apiKey, do nothing.
// If it exists but the apiKey differs (USB swapped, machine changed), leave the
// existing entry alone and log a hint — never overwrite user data silently.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { getFingerprint } from './fingerprint.mjs';
import { buildApiKey } from './xiapan-client.mjs';
const PROVIDER_ID = 'uclaw-cloud';
// Mirror ClawX commercial schema: keep models[] empty and steer model selection
// via agents.defaults.model.primary + fallbacks. OpenClaw 2026.4.x accepts both
// shapes, but the empty-models form lets the runtime auto-discover model ids
// from /v1/models without us hard-coding a list that drifts from the backend.
const DEFAULT_PROVIDER_TEMPLATE = {
baseUrl: 'https://api.u-claw.org/v1',
api: 'openai-completions',
models: [],
};
const DEFAULT_PRIMARY_MODEL = `${PROVIDER_ID}/deepseek-v4-flash`;
const DEFAULT_FALLBACK_MODELS = [
`${PROVIDER_ID}/deepseek-chat`,
`${PROVIDER_ID}/qwen-plus`,
`${PROVIDER_ID}/qwen-turbo`,
];
function readJsonSafe(filePath) {
if (!existsSync(filePath)) return null;
try {
const raw = readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch (err) {
process.stderr.write(`[bootstrap-xiapan] Cannot parse ${filePath}: ${err.message}\n`);
return null;
}
}
function writeJson(filePath, data) {
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
}
function ensureModelsContainer(config) {
if (!config.models || typeof config.models !== 'object') {
config.models = { mode: 'merge', providers: {} };
}
if (!config.models.mode) config.models.mode = 'merge';
if (!config.models.providers || typeof config.models.providers !== 'object') {
config.models.providers = {};
}
return config.models.providers;
}
// Set agents.defaults.model.primary to uclaw-cloud only when the user has not
// configured a primary model already. If they've picked a different provider
// (e.g. DeepSeek BYOK), leave their choice untouched.
function ensureAgentsDefaults(config) {
if (!config.agents || typeof config.agents !== 'object') {
config.agents = {};
}
if (!config.agents.defaults || typeof config.agents.defaults !== 'object') {
config.agents.defaults = {};
}
const defaults = config.agents.defaults;
if (!defaults.model || typeof defaults.model !== 'object') {
defaults.model = {};
}
let changed = false;
if (!defaults.model.primary) {
defaults.model.primary = DEFAULT_PRIMARY_MODEL;
changed = true;
}
if (!Array.isArray(defaults.model.fallbacks) || defaults.model.fallbacks.length === 0) {
defaults.model.fallbacks = [...DEFAULT_FALLBACK_MODELS];
changed = true;
}
return changed;
}
export async function bootstrapXiapan({ configPath, appRoot, log = console } = {}) {
if (!configPath) {
throw new Error('bootstrapXiapan: configPath is required.');
}
const root = appRoot || process.cwd();
let fingerprintInfo;
try {
fingerprintInfo = await getFingerprint(root);
} catch (err) {
log.warn?.(`[bootstrap-xiapan] Fingerprint detection failed: ${err.message}`);
return { ok: false, reason: 'fingerprint-failed' };
}
const apiKey = buildApiKey(fingerprintInfo.fingerprint);
const config = readJsonSafe(configPath) || { gateway: { mode: 'local', auth: { token: 'uclaw' } } };
const providers = ensureModelsContainer(config);
const existing = providers[PROVIDER_ID];
if (existing && typeof existing === 'object') {
if (existing.apiKey && existing.apiKey !== apiKey) {
log.info?.(
`[bootstrap-xiapan] uclaw-cloud apiKey already configured (different fingerprint). `
+ `Current source=${fingerprintInfo.source}. Use Config UI to rebind if needed.`,
);
// Still ensure agents.defaults exists so the runtime knows to use uclaw-cloud
const changedDefaults = ensureAgentsDefaults(config);
if (changedDefaults) writeJson(configPath, config);
return {
ok: true,
action: 'kept',
source: fingerprintInfo.source,
apiKey: existing.apiKey,
};
}
if (existing.apiKey === apiKey) {
const changedDefaults = ensureAgentsDefaults(config);
if (changedDefaults) {
writeJson(configPath, config);
log.info?.('[bootstrap-xiapan] Added agents.defaults to existing config');
}
return {
ok: true,
action: changedDefaults ? 'agents-defaults-added' : 'noop',
source: fingerprintInfo.source,
apiKey,
};
}
}
providers[PROVIDER_ID] = {
...DEFAULT_PROVIDER_TEMPLATE,
...(existing && typeof existing === 'object' ? existing : {}),
baseUrl: existing?.baseUrl || DEFAULT_PROVIDER_TEMPLATE.baseUrl,
api: existing?.api || DEFAULT_PROVIDER_TEMPLATE.api,
apiKey,
models: existing?.models ?? DEFAULT_PROVIDER_TEMPLATE.models,
};
ensureAgentsDefaults(config);
writeJson(configPath, config);
log.info?.(
`[bootstrap-xiapan] Wrote uclaw-cloud provider (source=${fingerprintInfo.source}, key=${apiKey.slice(0, 12)}…)`,
);
return {
ok: true,
action: existing ? 'updated' : 'created',
source: fingerprintInfo.source,
apiKey,
};
}
// CLI:
// node bootstrap-xiapan.mjs <config-path>
// env UCLAW_CONFIG_PATH=... node bootstrap-xiapan.mjs
import { pathToFileURL } from 'node:url';
const isMain = (() => {
try {
if (!process.argv[1]) return false;
return import.meta.url === pathToFileURL(process.argv[1]).href;
} catch {
return false;
}
})();
if (isMain) {
const configPath = process.argv[2] || process.env.UCLAW_CONFIG_PATH;
if (!configPath) {
process.stderr.write('Usage: node bootstrap-xiapan.mjs <openclaw.json path>\n');
process.exit(2);
}
const appRoot = process.env.UCLAW_APP_ROOT || resolve(configPath, '../../..');
bootstrapXiapan({ configPath, appRoot })
.then((res) => {
process.stdout.write(`${JSON.stringify(res)}\n`);
})
.catch((err) => {
process.stderr.write(`bootstrap-xiapan error: ${err.message}\n`);
process.exit(1);
});
}