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 { emitStatus } from '../status.js';
|
||||||
import {
|
import {
|
||||||
CHANNEL_AUTH_REGISTRY,
|
CHANNEL_AUTH_REGISTRY,
|
||||||
|
autoResolveV2Keys,
|
||||||
readHandoff,
|
readHandoff,
|
||||||
recordStep,
|
recordStep,
|
||||||
v1PathsFor,
|
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
|
// 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
|
// 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
|
// 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 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 v2Env = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : '';
|
||||||
const v2EnvKeys = new Set(
|
const v2EnvKeys = new Set(
|
||||||
v2Env
|
v2Env
|
||||||
@@ -179,7 +228,7 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
channel_type: ch.channel_type,
|
channel_type: ch.channel_type,
|
||||||
env_keys_copied: envKeysPresentInV1,
|
env_keys_copied: [...envKeysPresentInV1, ...resolvedKeys.map((k) => `${k} (auto-resolved)`)],
|
||||||
files_copied: filesCopied,
|
files_copied: filesCopied,
|
||||||
files_missing: filesMissing,
|
files_missing: filesMissing,
|
||||||
notes: spec.note ?? '',
|
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`
|
* 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
|
* script, if one exists. `null` means no v2 skill is available yet — the
|
||||||
|
|||||||
Reference in New Issue
Block a user