u-chan-secrets 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +233 -91
  2. package/dist/cli.js +115 -19
  3. package/dist/client/claude-setup.js +97 -0
  4. package/dist/client/codex-setup.js +6 -5
  5. package/dist/client/discord.js +67 -0
  6. package/dist/client/github.js +28 -0
  7. package/dist/client/setup.js +40 -6
  8. package/dist/client/update-package.js +166 -0
  9. package/dist/client/update.js +138 -0
  10. package/dist/client/windows-command.js +54 -0
  11. package/dist/core/browser.js +0 -5
  12. package/dist/core/config.js +1 -0
  13. package/dist/core/doctor.js +5 -1
  14. package/dist/core/vault.js +110 -5
  15. package/dist/discord/http.js +84 -0
  16. package/dist/discord/policy.js +47 -0
  17. package/dist/discord/service.js +349 -0
  18. package/dist/discord/store.js +85 -0
  19. package/dist/github/http.js +93 -0
  20. package/dist/github/policy.js +24 -0
  21. package/dist/github/service.js +185 -0
  22. package/dist/github/store.js +54 -0
  23. package/dist/index.js +1 -1
  24. package/dist/ipc/client.js +74 -6
  25. package/dist/ipc/server.js +38 -3
  26. package/dist/mcp/server.js +25 -2
  27. package/dist/platform/windows/assets/prompt-src.js +160 -38
  28. package/dist/platform/windows/doctor.js +2 -2
  29. package/dist/platform/windows/install.js +4 -0
  30. package/dist/service/control.js +47 -5
  31. package/dist/service/doctor.js +3 -0
  32. package/dist/service/main.js +34 -17
  33. package/dist/service/manage.js +20 -16
  34. package/dist/service/state.js +28 -0
  35. package/dist/service/update/coordinator.js +171 -0
  36. package/dist/service/update/files.js +47 -0
  37. package/dist/service/update/gate.js +21 -0
  38. package/dist/service/update/journal.js +157 -0
  39. package/dist/service/update/native.js +50 -0
  40. package/dist/service/update/network.js +28 -0
  41. package/dist/service/update/protocol.js +28 -0
  42. package/dist/service/update/release.js +102 -0
  43. package/dist/service/update/worker-main.js +116 -0
  44. package/dist/service/update/worker.js +58 -0
  45. package/package.json +1 -1
  46. package/scripts/debug-install.bat +69 -69
  47. package/scripts/desktop-browser.ps1 +110 -103
  48. package/scripts/login-bw.bat +21 -21
  49. package/scripts/migrate.bat +4 -4
  50. package/scripts/start-browser.ps1 +33 -33
  51. package/scripts/windows-security/desktop-matrix.mjs +54 -54
package/dist/cli.js CHANGED
@@ -7,15 +7,27 @@ import { fileURLToPath } from 'node:url';
7
7
  import { IpcClient } from './ipc/client.js';
8
8
  import { defaultEndpoint } from './ipc/endpoint.js';
9
9
  import { MAX_FRAME, encode } from './ipc/protocol.js';
10
- import { runDoctor, doctorExitCode, checkPlaywrightNow, chromeState, safeVersion, SERVICE_DIAGNOSTIC_TIMEOUT_MS } from './core/doctor.js';
10
+ import { runDoctor, doctorExitCode, bounded, checkPlaywrightNow, chromeState, safeVersion, SERVICE_DIAGNOSTIC_TIMEOUT_MS } from './core/doctor.js';
11
11
  import { loadConfig } from './core/config.js';
12
12
  import { execFile } from 'node:child_process';
13
13
  import { join } from 'node:path';
14
14
  import { configureCodex, isElevated } from './client/codex-setup.js';
15
- import { runSetup, setupExitCode } from './client/setup.js';
15
+ import { configureClaude } from './client/claude-setup.js';
16
+ import { combineClients, runSetup, setupExitCode, SETUP_CLIENT_NAMES } from './client/setup.js';
16
17
  import { parseAllowArgs } from './client/allow.js';
17
18
  import { saveBrowserChoice } from './client/browser-choice.js';
18
- const USAGE = 'usage: u-chan install [--mode <easy|hardcore>] [--phrase <phrase>] | uninstall | unlock | lock | mode <easy|hardcore> | import-v1 [path] | audit [limit] | update | doctor [--json] | manage';
19
+ import { configureWindowsCommand } from './client/windows-command.js';
20
+ import { runDiscordCli } from './client/discord.js';
21
+ import { runCoordinatedUpdate } from './client/update.js';
22
+ import { desktopUpdateAdapter } from './client/update-package.js';
23
+ import { runGithubCli } from './client/github.js';
24
+ const USAGE = 'usage: u-chan start | install [--mode <easy|hardcore>] [--phrase <phrase>] | uninstall | unlock | lock | mode <easy|hardcore> | import-v1 [path] | audit [limit] | update | doctor [--json] | manage';
25
+ /** AI client CLIs on PATH. Detection only runs `where`/`which`; it never executes the clients themselves. */
26
+ export function detectClients(platform = process.platform) {
27
+ const finder = platform === 'win32' ? 'where.exe' : 'which';
28
+ const found = (name) => new Promise(resolve => execFile(finder, [name], { timeout: 5000, windowsHide: true, maxBuffer: 64 * 1024 }, err => resolve(!err)));
29
+ return Promise.all(['codex', 'claude'].map(async (c) => (await found(c)) ? [c] : [])).then(r => r.flat());
30
+ }
19
31
  /** import-v1 shows one approval window per entry, each open for up to 2 minutes, one after another. */
20
32
  export const IMPORT_PROMPT_MS = 120000;
21
33
  export const IMPORT_MARGIN_MS = 60000;
@@ -27,6 +39,35 @@ function defaultV1Path(env, platform) {
27
39
  }
28
40
  /** Returns the process exit code: 0 on success, 1 on failure or bad usage. */
29
41
  export async function main(argv, d) {
42
+ if (argv[0] === 'github') {
43
+ const result = await runGithubCli(argv.slice(1), d.connect({ timeoutMs: 240000 }), d.readFile);
44
+ if (result.ok)
45
+ d.out(result.message);
46
+ else
47
+ d.err(result.message);
48
+ return result.ok ? 0 : 1;
49
+ }
50
+ if (argv[0] === 'update') {
51
+ if (argv.length > 2 || (argv.length === 2 && argv[1] !== '--check')) {
52
+ d.err('usage: u-chan update [--check]');
53
+ return 1;
54
+ }
55
+ const checkOnly = argv[1] === '--check';
56
+ const result = d.update ? await d.update(checkOnly) : await runCoordinatedUpdate({ checkOnly }, { client: d.connect({ timeoutMs: 180000 }), desktop: desktopUpdateAdapter(d.env), progress: (phase, seconds) => d.out(`Update: ${phase} (${seconds}s)`) });
57
+ if (result.ok)
58
+ d.out(result.message);
59
+ else
60
+ d.err(result.message);
61
+ return result.ok ? 0 : 1;
62
+ }
63
+ if (argv[0] === 'discord') {
64
+ const result = await runDiscordCli(argv.slice(1), d.connect({ timeoutMs: 240000 }), d.readFile);
65
+ if (result.ok)
66
+ d.out(result.message);
67
+ else
68
+ d.err(result.message);
69
+ return result.ok ? 0 : 1;
70
+ }
30
71
  const [cmd, arg, ...rest] = argv;
31
72
  let call;
32
73
  let diagnosticEnv = d.env;
@@ -58,6 +99,7 @@ export async function main(argv, d) {
58
99
  return state === 'ok' ? 'pass' : state === 'missing' ? 'fail' : 'unknown';
59
100
  },
60
101
  }));
102
+ const startBrowser = d.startBrowser ?? launchBrowser;
61
103
  const printDoctor = (r) => d.out(r.checks.map(c => `${c.state.toUpperCase()} ${c.id}: ${c.message}${c.nextAction ? ' ' + c.nextAction : ''}`).join('\n'));
62
104
  if (cmd === 'allow') {
63
105
  const proposal = parseAllowArgs(argv.slice(1));
@@ -79,6 +121,7 @@ export async function main(argv, d) {
79
121
  const args = argv.slice(1), seen = new Set();
80
122
  let valid = true;
81
123
  let browser;
124
+ let client = 'auto';
82
125
  for (let i = 0; i < args.length; i++) {
83
126
  const flag = args[i];
84
127
  if (seen.has(flag) || !['--client', '--browser', '--apply', '--replace-existing', '--start-browser', '--json'].includes(flag)) {
@@ -86,9 +129,14 @@ export async function main(argv, d) {
86
129
  break;
87
130
  }
88
131
  seen.add(flag);
89
- if (flag === '--client' && args[++i] !== 'codex') {
90
- valid = false;
91
- break;
132
+ if (flag === '--client') {
133
+ const value = args[++i];
134
+ if (value !== 'codex' && value !== 'claude' && value !== 'all') {
135
+ valid = false;
136
+ break;
137
+ }
138
+ ;
139
+ client = value;
92
140
  }
93
141
  if (flag === '--browser') {
94
142
  const value = args[++i];
@@ -101,27 +149,73 @@ export async function main(argv, d) {
101
149
  }
102
150
  }
103
151
  if (!valid || (seen.has('--replace-existing') && !seen.has('--apply'))) {
104
- d.err('usage: u-chan setup [--client codex] [--browser chrome|edge] [--apply] [--replace-existing] [--start-browser] [--json]');
152
+ d.err('usage: u-chan setup [--client codex|claude|all] [--browser chrome|edge] [--apply] [--replace-existing] [--start-browser] [--json]');
105
153
  return 1;
106
154
  }
107
155
  const options = { apply: seen.has('--apply'), replaceExisting: seen.has('--replace-existing'), startBrowser: seen.has('--start-browser'), ...(browser ? { browser } : {}) };
108
156
  if (browser)
109
157
  diagnosticEnv = { ...d.env, U_CHAN_BROWSER: browser };
110
158
  const cfg = loadConfig(diagnosticEnv, d.platform), endpoint = cfg.cdpEndpoint;
111
- const r = d.setup ? await d.setup(options) : await runSetup(options, {
112
- platform: d.platform, endpoint, browser: cfg.browser, elevated: isElevated, doctor,
113
- selectBrowser: choice => saveBrowserChoice(cfg.browserChoicePath, choice),
114
- configure: opts => configureCodex({ ...opts, browser: cfg.browser, endpoint, ipcEndpoint: defaultEndpoint(d.env, d.platform), nodePath: process.execPath, entryPath: fileURLToPath(new URL('./index.js', import.meta.url)), configDir: d.env.CODEX_HOME ?? join(homedir(), '.codex') }),
115
- startBrowser: () => new Promise(resolve => {
116
- execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', fileURLToPath(new URL('../scripts/start-browser.ps1', import.meta.url)), '-Browser', cfg.browser, '-Background'], { timeout: 24000, windowsHide: true, env: diagnosticEnv, maxBuffer: 65536 }, err => resolve(!err));
117
- }),
118
- });
159
+ const entryPath = fileURLToPath(new URL('./index.js', import.meta.url)), claudeDir = d.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude');
160
+ const configureFor = (c) => (opts) => c === 'claude'
161
+ ? configureClaude({ ...opts, browser: cfg.browser, endpoint, ipcEndpoint: defaultEndpoint(d.env, d.platform), nodePath: process.execPath, entryPath, configDir: claudeDir, configPath: d.env.CLAUDE_CONFIG_DIR ? join(claudeDir, '.claude.json') : join(homedir(), '.claude.json') })
162
+ : configureCodex({ ...opts, browser: cfg.browser, endpoint, ipcEndpoint: defaultEndpoint(d.env, d.platform), nodePath: process.execPath, entryPath, configDir: d.env.CODEX_HOME ?? join(homedir(), '.codex') });
163
+ let r;
164
+ if (d.setup)
165
+ r = await d.setup(options, client);
166
+ else {
167
+ const clients = client === 'all' ? ['codex', 'claude'] : client === 'auto' ? await (d.detectClients ?? detectClients)() : [client];
168
+ const combined = combineClients(clients.map(c => ({ client: c, configure: configureFor(c) })));
169
+ r = await runSetup(options, {
170
+ platform: d.platform, endpoint, browser: cfg.browser, elevated: isElevated, doctor, clientName: combined.clientName,
171
+ configureShell: apply => configureWindowsCommand(apply, d.env.PATH ?? d.env.Path ?? ''),
172
+ selectBrowser: choice => saveBrowserChoice(cfg.browserChoicePath, choice),
173
+ configure: combined.configure,
174
+ startBrowser: () => startBrowser(cfg.browser, diagnosticEnv, true),
175
+ });
176
+ if (clients.length > 1 && combined.results().length)
177
+ r.clients = combined.results();
178
+ }
119
179
  if (seen.has('--json'))
120
180
  d.out(JSON.stringify(r));
121
181
  else
122
- d.out([`Setup: ${r.state}; sharing: ${r.sharing}; restart required: ${r.restartRequired}`, ...r.actions.map(a => a.message), ...(r.backupPath ? ['Configuration backup: ' + r.backupPath] : [])].join('\n'));
182
+ d.out([`Setup: ${r.state}; sharing: ${r.sharing}; restart required: ${r.restartRequired}`, ...(r.clients ?? []).map(c => `${SETUP_CLIENT_NAMES[c.client]}: ${c.state}. ${c.message}`), ...r.actions.map(a => a.message), ...(r.backupPath ? ['Configuration backup: ' + r.backupPath] : [])].join('\n'));
123
183
  return setupExitCode(r);
124
184
  }
185
+ if (cmd === 'start' && arg === undefined) {
186
+ // Daily routine: dedicated browser, vault unlock through the protected prompt, then a doctor summary.
187
+ if (d.platform !== 'win32') {
188
+ d.err('u-chan start supports Windows only; start the browser and run u-chan unlock manually.');
189
+ return 1;
190
+ }
191
+ const cfg = loadConfig(d.env, d.platform);
192
+ if (await chromeState(cfg.cdpEndpoint) === 'pass')
193
+ d.out('Browser: ready.');
194
+ else if (await bounded(() => startBrowser(cfg.browser, d.env, false), false, 25000))
195
+ d.out('Browser: started.');
196
+ else
197
+ d.err('Browser: could not start the dedicated browser; run u-chan doctor.');
198
+ let vault;
199
+ try {
200
+ const r = await d.connect({ timeoutMs: SERVICE_DIAGNOSTIC_TIMEOUT_MS }).diagnose();
201
+ vault = r.ok ? JSON.parse(r.message)?.vault : undefined;
202
+ }
203
+ catch {
204
+ vault = undefined;
205
+ }
206
+ if (vault === 'unlocked')
207
+ d.out('Vault: unlocked.');
208
+ else if (vault === 'locked') {
209
+ const r = await d.connect({}).unlock();
210
+ if (r.ok)
211
+ d.out('Vault: ' + r.message);
212
+ else
213
+ d.err('Vault: ' + r.message);
214
+ }
215
+ const report = await doctor();
216
+ printDoctor(report);
217
+ return doctorExitCode(report) === 1 || report.checks.some(c => c.id === 'vault' && c.nextAction === 'Run u-chan unlock.') ? 1 : 0;
218
+ }
125
219
  if (cmd === 'doctor' && (arg === undefined || arg === '--json') && rest.length === 0) {
126
220
  const r = await doctor();
127
221
  if (arg === '--json')
@@ -233,9 +327,6 @@ export async function main(argv, d) {
233
327
  }
234
328
  call = () => d.connect({}).audit(limit);
235
329
  }
236
- else if (cmd === 'update' && arg === undefined) {
237
- call = () => d.connect({ timeoutMs: 120000 }).update();
238
- }
239
330
  else if (cmd === 'manage' && arg === undefined) {
240
331
  call = () => d.connect({ timeoutMs: 15 * 60_000 }).manage();
241
332
  }
@@ -252,6 +343,11 @@ export async function main(argv, d) {
252
343
  printDoctor(await doctor());
253
344
  return r.ok ? 0 : 1;
254
345
  }
346
+ function launchBrowser(browser, env, background) {
347
+ return new Promise(resolve => {
348
+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', fileURLToPath(new URL('../scripts/start-browser.ps1', import.meta.url)), '-Browser', browser, ...(background ? ['-Background'] : [])], { timeout: 24000, windowsHide: true, env, maxBuffer: 65536 }, err => resolve(!err));
349
+ });
350
+ }
255
351
  const isEntry = (() => {
256
352
  try {
257
353
  return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
@@ -0,0 +1,97 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
4
+ import { safeCdpOrigin } from '../core/doctor.js';
5
+ import { defaultEndpoint } from '../ipc/endpoint.js';
6
+ import { browserKind } from './browser-choice.js';
7
+ import { backupFile, isElevated } from './codex-setup.js';
8
+ function simple(entry, name) {
9
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
10
+ return false;
11
+ if (Object.keys(entry).some(k => !['type', 'command', 'args', 'env'].includes(k)) || (entry.type ?? 'stdio') !== 'stdio')
12
+ return false;
13
+ if (typeof entry.command !== 'string' || !Array.isArray(entry.args) || !entry.args.every((a) => typeof a === 'string'))
14
+ return false;
15
+ if (entry.env != null && (typeof entry.env !== 'object' || Array.isArray(entry.env) || Object.entries(entry.env).some(([key, value]) => name !== 'u-chan-secrets' || (key === 'U_CHAN_BROWSER' ? value !== 'chrome' && value !== 'edge' : key === 'U_CHAN_CDP' ? !safeCdpOrigin(value) : true))))
16
+ return false;
17
+ const command = basename(entry.command.replaceAll('\\', '/')).toLowerCase();
18
+ if (name === 'u-chan-secrets')
19
+ return ['node', 'node.exe'].includes(command) && entry.args.length === 1 && /(?:^|[\\/])index\.js$/.test(entry.args[0]);
20
+ return ['npx', 'npx.cmd', 'npx.exe'].includes(command) && /^@playwright\/mcp(?:@[^\s]+)?$/.test(entry.args[0] ?? '')
21
+ && (entry.args.length === 1 || (entry.args.length === 3 && entry.args[1] === '--cdp-endpoint' && !!safeCdpOrigin(entry.args[2])));
22
+ }
23
+ /** Inspect Claude Code's user-scope JSON read-only; every change goes through the official `claude mcp` CLI. */
24
+ export async function configureClaude(o, injected) {
25
+ let changed = false, backupPath;
26
+ const result = (state, message) => ({ state, changed, ...(backupPath ? { backupPath } : {}), message });
27
+ const d = injected ?? { elevated: isElevated, read: path => readFile(path, 'utf8'), exec: args => new Promise(resolve => {
28
+ execFile('claude', args, { timeout: 30000, windowsHide: true, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => resolve({ code: err ? 1 : 0, stdout, stderr }));
29
+ }) };
30
+ try {
31
+ const origin = safeCdpOrigin(o.endpoint);
32
+ if (!origin || (o.replaceExisting && !o.apply))
33
+ return result('manual', 'Use a safe loopback browser target and explicit apply flags.');
34
+ if (o.browser !== undefined)
35
+ browserKind(o.browser);
36
+ if (await d.elevated())
37
+ return result('unavailable', 'Run setup from a non-administrator desktop terminal.');
38
+ let servers = {};
39
+ try {
40
+ const text = await d.read(o.configPath);
41
+ const parsed = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
42
+ if (!parsed || typeof parsed !== 'object')
43
+ return result('unavailable', 'Could not inspect Claude Code registrations.');
44
+ servers = parsed.mcpServers && typeof parsed.mcpServers === 'object' ? parsed.mcpServers : {};
45
+ }
46
+ catch (err) {
47
+ if (err.code !== 'ENOENT')
48
+ return result('unavailable', 'Could not inspect Claude Code registrations.');
49
+ }
50
+ const env = o.browser ? { U_CHAN_BROWSER: o.browser, U_CHAN_CDP: origin } : {};
51
+ const desired = [
52
+ { name: 'u-chan-secrets', server: { type: 'stdio', command: o.nodePath, args: [o.entryPath], env } },
53
+ { name: 'playwright', server: { type: 'stdio', command: 'npx', args: ['@playwright/mcp@latest', '--cdp-endpoint', origin], env: {} } },
54
+ ];
55
+ const ipc = o.ipcEndpoint ?? defaultEndpoint(process.env, process.platform);
56
+ const pending = [];
57
+ for (const { name, server } of desired) {
58
+ const current = servers[name];
59
+ if (current === undefined) {
60
+ pending.push({ name, server, replace: false });
61
+ continue;
62
+ }
63
+ const curEnv = current?.env && typeof current.env === 'object' ? current.env : {};
64
+ if (Object.keys(curEnv).some(key => {
65
+ const k = key.toUpperCase();
66
+ return k === 'U_CHAN_CDP' ? safeCdpOrigin(curEnv[key]) !== origin : k === 'U_CHAN_IPC' && curEnv[key] !== ipc;
67
+ }))
68
+ return result('manual', 'Custom target environment preserved; align browser and service targets manually.');
69
+ if ((current?.type ?? 'stdio') === 'stdio' && current?.command === server.command && JSON.stringify(current.args) === JSON.stringify(server.args)
70
+ && (name !== 'u-chan-secrets' || o.browser === undefined || (curEnv.U_CHAN_BROWSER === o.browser && safeCdpOrigin(curEnv.U_CHAN_CDP) === origin)))
71
+ continue;
72
+ if (!simple(current, name))
73
+ return result('manual', 'Custom Claude Code settings preserved; configure this registration manually.');
74
+ pending.push({ name, server, replace: true });
75
+ }
76
+ if (pending.length === 0)
77
+ return result('ready', 'Claude Code registrations match; active session ownership is unverified.');
78
+ const replaces = pending.some(p => p.replace);
79
+ if (!o.apply || (replaces && !o.replaceExisting))
80
+ return result(replaces ? 'needs_update' : 'missing', 'Explicit setup apply is required; replacements also require --replace-existing.');
81
+ backupPath = await backupFile(o.configPath, join(o.configDir, '#0 backup'), '.json');
82
+ for (const { name, server, replace } of pending) {
83
+ if (replace) {
84
+ if ((await d.exec(['mcp', 'remove', '--scope', 'user', name])).code !== 0)
85
+ return result('unavailable', 'Claude Code change failed; inspect configuration before retrying.');
86
+ changed = true;
87
+ }
88
+ if ((await d.exec(['mcp', 'add-json', '--scope', 'user', name, JSON.stringify(server)])).code !== 0)
89
+ return result('unavailable', 'Claude Code change failed; completed changes and backup were retained.');
90
+ changed = true;
91
+ }
92
+ return result('ready', 'Claude Code registrations saved. Restart the AI session; active ownership remains unverified.');
93
+ }
94
+ catch {
95
+ return result('unavailable', 'Claude Code setup unavailable; inspect configuration and any retained backup before retrying.');
96
+ }
97
+ }
@@ -43,7 +43,10 @@ async function backup(configDir) {
43
43
  const info = await lstat(root);
44
44
  if (!info.isDirectory() || info.isSymbolicLink() || (await realpath(root)).toLowerCase() !== root.toLowerCase())
45
45
  throw new Error('redirected config');
46
- const config = join(root, 'config.toml');
46
+ return backupFile(join(root, 'config.toml'), join(root, '#0 backup'), '.toml');
47
+ }
48
+ /** Copy a regular config file into a non-redirected backup directory; undefined when the file does not exist. */
49
+ export async function backupFile(config, dir, ext) {
47
50
  try {
48
51
  const file = await lstat(config);
49
52
  if (!file.isFile() || file.isSymbolicLink())
@@ -54,13 +57,11 @@ async function backup(configDir) {
54
57
  return undefined;
55
58
  throw err;
56
59
  }
57
- const dir = join(root, '#0 backup');
58
- await mkdir(dir, { mode: 0o700 }).catch(err => { if (err.code !== 'EEXIST')
59
- throw err; });
60
+ await mkdir(dir, { recursive: true, mode: 0o700 });
60
61
  const target = await lstat(dir);
61
62
  if (!target.isDirectory() || target.isSymbolicLink() || (await realpath(dir)).toLowerCase() !== dir.toLowerCase())
62
63
  throw new Error('redirected backup');
63
- const path = join(dir, `u-chan-setup-${randomUUID()}.toml`);
64
+ const path = join(dir, `u-chan-setup-${randomUUID()}${ext}`);
64
65
  await copyFile(config, path, constants.COPYFILE_EXCL);
65
66
  await chmod(path, 0o600);
66
67
  return path;
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod';
2
+ import { aliasSchema, batchSchema, connectionRequestSchema, snowflake } from '../discord/policy.js';
3
+ const connectionResult = z.object({ guild: z.object({ id: snowflake }), totals: z.object({ channels: z.number().int().min(0).max(1000), roles: z.number().int().min(0).max(1000) }) });
4
+ /** Reuse approved metadata inspection; return only a bounded health summary, never raw metadata. */
5
+ export async function testDiscordConnection(client, alias) {
6
+ if (!aliasSchema.safeParse(alias).success)
7
+ return { ok: false, message: 'invalid Discord alias' };
8
+ if (!client.discordInspect)
9
+ return { ok: false, message: 'Discord broker unavailable; update the local client' };
10
+ let result;
11
+ try {
12
+ result = await client.discordInspect(alias);
13
+ }
14
+ catch {
15
+ return { ok: false, message: 'Discord connection test unavailable; check service and vault status' };
16
+ }
17
+ if (!result.ok)
18
+ return result;
19
+ try {
20
+ const data = connectionResult.parse(JSON.parse(result.message));
21
+ return { ok: true, message: JSON.stringify({ connection: alias, status: 'connected', readOnly: true, serverId: data.guild.id, channels: data.totals.channels, roles: data.totals.roles }) };
22
+ }
23
+ catch {
24
+ return { ok: false, message: 'Discord connection test received an invalid service response' };
25
+ }
26
+ }
27
+ export const DISCORD_USAGE = 'usage: u-chan discord allow <connection.json> | list | test <alias> | inspect <alias> [member-id] [--page <0..999>] | apply <alias> <operations.json> | revoke <alias>';
28
+ export async function runDiscordCli(args, client, read) {
29
+ const [cmd, arg, third] = args;
30
+ const invalid = { ok: false, message: DISCORD_USAGE };
31
+ try {
32
+ if (cmd === 'test' && args.length === 2)
33
+ return await testDiscordConnection(client, arg);
34
+ if (cmd === 'list' && args.length === 1)
35
+ return await client.discordListAllowed?.() ?? invalid;
36
+ if (cmd === 'revoke' && args.length === 2 && aliasSchema.safeParse(arg).success)
37
+ return await client.discordRevoke?.(arg) ?? invalid;
38
+ if (cmd === 'inspect' && aliasSchema.safeParse(arg).success) {
39
+ let member, page = 0;
40
+ const extra = args.slice(2);
41
+ if (extra[0] && extra[0] !== '--page') {
42
+ member = extra.shift();
43
+ if (!snowflake.safeParse(member).success)
44
+ return invalid;
45
+ }
46
+ if (extra.length) {
47
+ if (extra.length !== 2 || extra[0] !== '--page' || !/^(0|[1-9][0-9]{0,2})$/.test(extra[1]))
48
+ return invalid;
49
+ page = Number(extra[1]);
50
+ }
51
+ return await client.discordInspect?.(arg, member, page) ?? invalid;
52
+ }
53
+ if ((cmd === 'allow' && args.length === 2) || (cmd === 'apply' && args.length === 3 && aliasSchema.safeParse(arg).success)) {
54
+ const raw = await read(cmd === 'allow' ? arg : third);
55
+ if (Buffer.byteLength(raw) > 16384)
56
+ return { ok: false, message: 'Discord input file is too large' };
57
+ const data = JSON.parse(raw);
58
+ if (cmd === 'allow')
59
+ return connectionRequestSchema.safeParse(data).success ? await client.discordRequestAllow?.(data) ?? invalid : invalid;
60
+ return batchSchema.safeParse(data).success ? await client.discordApply?.(arg, data) ?? invalid : invalid;
61
+ }
62
+ return invalid;
63
+ }
64
+ catch {
65
+ return { ok: false, message: 'Discord command failed; check input JSON and service availability' };
66
+ }
67
+ }
@@ -0,0 +1,28 @@
1
+ import { aliasSchema, githubProposalSchema, githubReadSchema, githubCreateSchema } from '../github/policy.js';
2
+ export const GITHUB_USAGE = 'usage: u-chan github allow <connection.json> | list | test <alias> | read <alias> <request.json> | create <alias> <operation.json> | revoke <alias>';
3
+ export async function runGithubCli(args, client, read) {
4
+ const [command, alias, file] = args, invalid = { ok: false, message: GITHUB_USAGE };
5
+ try {
6
+ if (command === 'list' && args.length === 1)
7
+ return await client.githubListAllowed?.() ?? invalid;
8
+ if (command === 'revoke' && args.length === 2 && aliasSchema.safeParse(alias).success)
9
+ return await client.githubRevoke?.(alias) ?? invalid;
10
+ if (command === 'test' && args.length === 2 && aliasSchema.safeParse(alias).success)
11
+ return await client.githubRead?.(alias, { resource: 'repository' }) ?? invalid;
12
+ if ((command === 'allow' && args.length === 2) || (['read', 'create'].includes(command) && args.length === 3 && aliasSchema.safeParse(alias).success)) {
13
+ const raw = await read(command === 'allow' ? alias : file);
14
+ if (Buffer.byteLength(raw) > 16384)
15
+ return { ok: false, message: 'GitHub input file is too large' };
16
+ const data = JSON.parse(raw);
17
+ if (command === 'allow')
18
+ return githubProposalSchema.safeParse(data).success ? await client.githubRequestAllow?.(data) ?? invalid : invalid;
19
+ if (command === 'read')
20
+ return githubReadSchema.safeParse(data).success ? await client.githubRead?.(alias, data) ?? invalid : invalid;
21
+ return githubCreateSchema.safeParse(data).success ? await client.githubCreate?.(alias, data) ?? invalid : invalid;
22
+ }
23
+ return invalid;
24
+ }
25
+ catch {
26
+ return { ok: false, message: 'GitHub command failed; check JSON input and service availability' };
27
+ }
28
+ }
@@ -2,6 +2,7 @@ import { bounded, safeCdpOrigin } from '../core/doctor.js';
2
2
  export async function runSetup(o, d) {
3
3
  const r = { version: 1, state: 'needs_attention', sharing: 'unobserved', restartRequired: false, checks: [], actions: [], client: 'unavailable', changed: false };
4
4
  const action = (id, message, required = true) => r.actions.push({ id, message, required });
5
+ const clientName = d.clientName ?? 'Codex';
5
6
  if (d.browser || o.browser)
6
7
  r.browser = o.browser ?? d.browser;
7
8
  try {
@@ -15,10 +16,15 @@ export async function runSetup(o, d) {
15
16
  }
16
17
  r.checks = (await d.doctor()).checks;
17
18
  if (d.platform !== 'win32') {
18
- action('configure_mcp', 'Automatic setup supports Windows and Codex only; configure other platforms manually.');
19
+ action('configure_mcp', `Automatic ${clientName} setup supports Windows only; configure other platforms manually.`);
19
20
  r.client = 'manual';
20
21
  return r;
21
22
  }
23
+ if (d.configureShell) {
24
+ r.shell = await d.configureShell(o.apply);
25
+ r.changed = r.shell.changed;
26
+ action('configure_shell', r.shell.message, !r.shell.ready);
27
+ }
22
28
  if (r.checks.some(c => c.id === 'browser_target' && c.state === 'fail')) {
23
29
  r.sharing = 'mismatch';
24
30
  action('configure_mcp', 'Align the desktop and service loopback browser targets before applying setup.');
@@ -26,9 +32,9 @@ export async function runSetup(o, d) {
26
32
  }
27
33
  const configured = await d.configure({ apply: o.apply, replaceExisting: o.replaceExisting, ...(o.browser ? { browser: o.browser } : {}) });
28
34
  r.client = configured.state;
29
- r.changed = configured.changed;
35
+ r.changed ||= configured.changed;
30
36
  r.backupPath = configured.backupPath;
31
- r.restartRequired = r.changed;
37
+ r.restartRequired = configured.changed;
32
38
  if (o.browser && o.apply && r.client === 'ready') {
33
39
  if (!d.selectBrowser)
34
40
  throw new Error('browser selection unavailable');
@@ -48,7 +54,7 @@ export async function runSetup(o, d) {
48
54
  const pw = r.checks.find(c => c.id === 'playwright');
49
55
  const mismatch = r.checks.some(c => c.id === 'browser_target' && c.state === 'fail');
50
56
  r.sharing = mismatch || pw?.state === 'fail' ? 'mismatch' : pw?.state === 'pass' ? 'process_configured' : 'unobserved';
51
- r.restartRequired = r.changed || pw?.state === 'fail';
57
+ r.restartRequired ||= pw?.state === 'fail';
52
58
  const ids = { registration: 'install_service', service: 'install_service', ipc: 'install_service',
53
59
  version: 'update_service', assets: 'repair_permissions', permissions: 'repair_permissions', service_browser: 'start_browser', chrome: 'start_browser',
54
60
  browser_target: 'configure_mcp', browser_identity: 'start_browser', playwright: 'configure_mcp', uac: 'review_uac' };
@@ -58,14 +64,14 @@ export async function runSetup(o, d) {
58
64
  action(id, c.nextAction, c.required && c.state !== 'pass');
59
65
  }
60
66
  if (r.client !== 'ready')
61
- action('configure_mcp', 'Inspect Codex configuration; apply missing registrations explicitly and preserve custom settings.');
67
+ action('configure_mcp', `Inspect ${clientName} configuration; apply missing registrations explicitly and preserve custom settings.`);
62
68
  if (!started)
63
69
  action('start_browser', 'Selected dedicated browser is unavailable or mismatched. Close only the previous dedicated U-Chan instance before switching, then run setup again.');
64
70
  if (r.restartRequired)
65
71
  action('restart_client', 'Restart the AI session to load saved MCP registrations.', false);
66
72
  action('configure_mcp', 'Active AI-session browser ownership is unverified; check the current client browser connection.', false);
67
73
  const requiredOk = r.checks.length > 0 && r.checks.filter(c => c.required).every(c => c.state === 'pass');
68
- if (requiredOk && r.client === 'ready' && started && !mismatch)
74
+ if (requiredOk && r.client === 'ready' && started && !mismatch && r.shell?.ready !== false)
69
75
  r.state = 'configured';
70
76
  return r;
71
77
  }
@@ -77,3 +83,31 @@ export async function runSetup(o, d) {
77
83
  export function setupExitCode(r) {
78
84
  return r.state === 'needs_attention' ? 1 : r.restartRequired ? 2 : 0;
79
85
  }
86
+ export const SETUP_CLIENT_NAMES = { codex: 'Codex', claude: 'Claude Code' };
87
+ const STATE_RANK = ['ready', 'missing', 'needs_update', 'manual', 'unavailable'];
88
+ /** Configure several AI clients in order; the combined state is the least ready one, and every client is attempted. */
89
+ export function combineClients(entries) {
90
+ let last = [];
91
+ return {
92
+ clientName: entries.length ? entries.map(e => SETUP_CLIENT_NAMES[e.client]).join(' and ') : 'Codex or Claude Code',
93
+ results: () => last,
94
+ configure: async (options) => {
95
+ last = [];
96
+ if (entries.length === 0)
97
+ return { state: 'unavailable', changed: false, message: 'No Codex or Claude Code CLI found on PATH; install one or pass --client.' };
98
+ for (const e of entries) {
99
+ let r;
100
+ try {
101
+ r = await e.configure(options);
102
+ }
103
+ catch {
104
+ r = { state: 'unavailable', changed: false, message: `${SETUP_CLIENT_NAMES[e.client]} setup unavailable.` };
105
+ }
106
+ last.push({ client: e.client, ...r });
107
+ }
108
+ const state = last.reduce((w, r) => STATE_RANK.indexOf(r.state) > STATE_RANK.indexOf(w) ? r.state : w, 'ready');
109
+ const backups = last.flatMap(r => r.backupPath ? [r.backupPath] : []);
110
+ return { state, changed: last.some(r => r.changed), ...(backups.length ? { backupPath: backups.join('; ') } : {}), message: last.map(r => `${SETUP_CLIENT_NAMES[r.client]}: ${r.message}`).join(' ') };
111
+ },
112
+ };
113
+ }