feat(migrate-v1): auto-resolve missing v2 channel keys via adapter APIs
`migrate-channel-auth` now tries to derive v2-required keys that v1 never
stored by calling the channel's API with the credential v1 did have. When
the gap can be closed automatically, the keys land in v2 `.env` before
the missing-required check, and the step reports `success` instead of
`partial`. When it can't, the existing followup fires unchanged.
## Discord
v1 used raw `discord.js` (bot token only). v2's Chat SDK needs
`DISCORD_APPLICATION_ID` + `DISCORD_PUBLIC_KEY`. Both can be fetched with
the bot token via:
GET /oauth2/applications/@me
Authorization: Bot <DISCORD_BOT_TOKEN>
→ { id, verify_key, … }
For a stock v1 Discord user, this means `bash nanoclaw.sh` now produces
a fully working v2 Discord adapter with zero manual key-setting — just
stop v1, and v2 takes over.
## Surface
- `autoResolveV2Keys(channelType, lookup)` in `setup/migrate-v1/shared.ts`
— pluggable per-channel resolver, returns a `{key: value}` map. Never
throws; returns `{}` on any failure (network, auth, unexpected shape).
Logs keys resolved, never values.
- `migrate-channel-auth` wiring: build a lookup over v1 + v2 .env, call
the resolver, append resolved keys to v2 .env (never overwriting), sync
to `data/env/env`, then re-check `requiredV2Keys` to compute the real
gap. Sidecar annotation `(auto-resolved)` on `env_keys_copied` in the
handoff so the skill can tell which came from v1 vs derived.
## Extending to other channels
Slack has `/auth.test` (bot token → team/app info), Telegram has `/getMe`,
Matrix has `/whoami`. Most don't cover the full required-key set v2 needs
(e.g. Slack's `SLACK_SIGNING_SECRET` lives only in app config and has no
API equivalent). Add resolvers case-by-case when the API supports it; the
registry's `requiredV2Keys` + followup fallback covers the rest.
## Testing
- Stripped `DISCORD_APPLICATION_ID` + `DISCORD_PUBLIC_KEY` from v2 `.env`
- Re-ran migration (wired-only, 301 groups): resolver populated both keys
via the API; `migrate-channel-auth: success` (was `partial`);
`overall_status: success`
- Restarted v2: Discord adapter booted clean, Gateway connected,
`GUILD_CREATE` received
- v1 stopped, v2 handling Discord traffic
This commit is contained in:
@@ -17,6 +17,7 @@ import path from 'path';
|
||||
import { emitStatus } from '../status.js';
|
||||
import {
|
||||
CHANNEL_AUTH_REGISTRY,
|
||||
autoResolveV2Keys,
|
||||
readHandoff,
|
||||
recordStep,
|
||||
v1PathsFor,
|
||||
@@ -126,8 +127,56 @@ export async function run(_args: string[]): Promise<void> {
|
||||
// Check v2's .env for required keys the v2 adapter needs to boot. v1
|
||||
// may not have had all of them (e.g. v1's Discord used discord.js
|
||||
// directly and never stored DISCORD_PUBLIC_KEY which v2's Chat SDK
|
||||
// requires). Surface missing ones as actionable followups.
|
||||
// requires). Try to auto-resolve the gap by calling the channel's API
|
||||
// with the v1 credential; fall through to a followup for anything we
|
||||
// can't resolve.
|
||||
const v2EnvPath = path.join(process.cwd(), '.env');
|
||||
const v1EnvMap = new Map<string, string>();
|
||||
for (const line of v1Env.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
v1EnvMap.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1));
|
||||
}
|
||||
|
||||
// Also let the resolver reach into v2's .env (migrate-env already merged
|
||||
// v1 keys into v2). Either source is fine for derivation inputs.
|
||||
const v2EnvPre = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : '';
|
||||
const v2EnvPreMap = new Map<string, string>();
|
||||
for (const line of v2EnvPre.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
v2EnvPreMap.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1));
|
||||
}
|
||||
|
||||
const resolved = await autoResolveV2Keys(
|
||||
ch.channel_type,
|
||||
(key) => v1EnvMap.get(key) ?? v2EnvPreMap.get(key),
|
||||
);
|
||||
const resolvedKeys = Object.keys(resolved);
|
||||
if (resolvedKeys.length > 0) {
|
||||
// Append to v2 .env (never overwriting existing values) + sync the
|
||||
// container-side copy. Log keys, never values.
|
||||
let text = v2EnvPre;
|
||||
if (text && !text.endsWith('\n')) text += '\n';
|
||||
for (const [key, value] of Object.entries(resolved)) {
|
||||
if (v2EnvPreMap.has(key)) continue;
|
||||
text += `${key}=${value}\n`;
|
||||
}
|
||||
fs.writeFileSync(v2EnvPath, text);
|
||||
try {
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env'));
|
||||
} catch {
|
||||
// Best-effort; service restart rehydrates it if needed.
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read v2 .env after possible resolution to compute the real gap.
|
||||
const v2Env = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : '';
|
||||
const v2EnvKeys = new Set(
|
||||
v2Env
|
||||
@@ -179,7 +228,7 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
results.push({
|
||||
channel_type: ch.channel_type,
|
||||
env_keys_copied: envKeysPresentInV1,
|
||||
env_keys_copied: [...envKeysPresentInV1, ...resolvedKeys.map((k) => `${k} (auto-resolved)`)],
|
||||
files_copied: filesCopied,
|
||||
files_missing: filesMissing,
|
||||
notes: spec.note ?? '',
|
||||
|
||||
@@ -518,6 +518,50 @@ export const CHANNEL_AUTH_REGISTRY: Record<string, ChannelAuthSpec> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* For channels where v2's adapter needs keys v1 never stored (e.g. Discord's
|
||||
* Chat SDK wants DISCORD_APPLICATION_ID + DISCORD_PUBLIC_KEY, but v1 used
|
||||
* raw discord.js with just the bot token), try to derive the missing keys
|
||||
* from the v1 creds we already have by calling the channel's API.
|
||||
*
|
||||
* Returns a map of key → value for what we successfully resolved.
|
||||
* Never throws; returns `{}` on any failure (network, auth, unexpected
|
||||
* shape). The caller writes the resolved keys to v2 .env, then re-checks
|
||||
* `requiredV2Keys` so the step reports `success` instead of `partial` when
|
||||
* auto-resolution covered the gap.
|
||||
*
|
||||
* Adding a new channel resolver: pull the needed values from an endpoint
|
||||
* that accepts only the v1-side credential (bot token, API key). Don't
|
||||
* prompt, don't log values. If the endpoint has rate limits, keep this
|
||||
* best-effort and fail silently.
|
||||
*/
|
||||
export async function autoResolveV2Keys(
|
||||
channelType: string,
|
||||
v1EnvLookup: (key: string) => string | undefined,
|
||||
): Promise<Record<string, string>> {
|
||||
if (channelType === 'discord') {
|
||||
const token = v1EnvLookup('DISCORD_BOT_TOKEN');
|
||||
if (!token) return {};
|
||||
try {
|
||||
const resp = await fetch('https://discord.com/api/v10/oauth2/applications/@me', {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!resp.ok) return {};
|
||||
const data = (await resp.json()) as { id?: string; verify_key?: string };
|
||||
const out: Record<string, string> = {};
|
||||
if (typeof data.id === 'string' && data.id) out.DISCORD_APPLICATION_ID = data.id;
|
||||
if (typeof data.verify_key === 'string' && data.verify_key) {
|
||||
out.DISCORD_PUBLIC_KEY = data.verify_key;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a v2 `channel_type` name to the corresponding `setup/install-<x>.sh`
|
||||
* script, if one exists. `null` means no v2 skill is available yet — the
|
||||
|
||||
Reference in New Issue
Block a user