@afffun/codexbot 1.0.71 → 1.0.73

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.
package/bin/codexbot.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { runCodexbotCli } from '../src/entrypoint.mjs';
3
+ import { formatCliError, runCodexbotCli } from '../src/entrypoint.mjs';
4
4
 
5
- const exitCode = await runCodexbotCli(process.argv.slice(2));
6
- process.exit(exitCode);
5
+ try {
6
+ const exitCode = await runCodexbotCli(process.argv.slice(2));
7
+ process.exit(exitCode);
8
+ } catch (err) {
9
+ process.stderr.write(`${formatCliError(err)}\n`);
10
+ process.exit(1);
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afffun/codexbot",
3
- "version": "1.0.71",
3
+ "version": "1.0.73",
4
4
  "description": "Thin npm bootstrap CLI for installing and operating Codexbot nodes",
5
5
  "type": "module",
6
6
  "author": "john88188 <john88188@outlook.com>",
@@ -0,0 +1,262 @@
1
+ import fsSyncDefault from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import readline from 'node:readline/promises';
7
+
8
+ import { buildInstallLicenseClaimUrl, getDefaultDistributionConfig } from './config_service.mjs';
9
+ import { resolveInstalledControlLayout } from './installed_layout_service.mjs';
10
+
11
+ function trim(value, fallback = '') {
12
+ const text = String(value ?? '').trim();
13
+ return text || String(fallback ?? '').trim();
14
+ }
15
+
16
+ function createPrompter({ stdin, stdout } = {}) {
17
+ const input = stdin || process.stdin;
18
+ const output = stdout || process.stdout;
19
+ if (!input?.isTTY || !output?.isTTY) {
20
+ return {
21
+ interactive: false,
22
+ ask: async () => '',
23
+ close: async () => {},
24
+ };
25
+ }
26
+ const rl = readline.createInterface({ input, output });
27
+ return {
28
+ interactive: true,
29
+ ask: async (prompt) => trim(await rl.question(prompt)),
30
+ close: async () => {
31
+ rl.close();
32
+ },
33
+ };
34
+ }
35
+
36
+ function runChildCapture(spawnFn, command, args, options = {}) {
37
+ return new Promise((resolve, reject) => {
38
+ const child = spawnFn(command, args, {
39
+ stdio: ['ignore', 'pipe', 'pipe'],
40
+ ...options,
41
+ });
42
+ let stdout = '';
43
+ let stderr = '';
44
+ child.stdout?.on('data', (chunk) => {
45
+ stdout += Buffer.from(chunk).toString('utf8');
46
+ });
47
+ child.stderr?.on('data', (chunk) => {
48
+ stderr += Buffer.from(chunk).toString('utf8');
49
+ });
50
+ child.on('error', reject);
51
+ child.on('exit', (code, signal) => {
52
+ if (signal) {
53
+ reject(new Error(`${command} exited via signal ${signal}`));
54
+ return;
55
+ }
56
+ resolve({ code: Number(code || 0), stdout, stderr });
57
+ });
58
+ });
59
+ }
60
+
61
+ function runChildInherit(spawnFn, command, args, options = {}) {
62
+ return new Promise((resolve, reject) => {
63
+ const child = spawnFn(command, args, {
64
+ stdio: 'inherit',
65
+ ...options,
66
+ });
67
+ child.on('error', reject);
68
+ child.on('exit', (code, signal) => {
69
+ if (signal) {
70
+ reject(new Error(`${command} exited via signal ${signal}`));
71
+ return;
72
+ }
73
+ resolve(Number(code || 0));
74
+ });
75
+ });
76
+ }
77
+
78
+ function resolveControlLayout({ fsSync, pathModule, processImpl, options }) {
79
+ return resolveInstalledControlLayout({
80
+ fsSync,
81
+ pathModule,
82
+ env: processImpl.env,
83
+ profile: options.layoutProfile,
84
+ appDir: options.appDir,
85
+ controlEnvFile: options.controlEnvFile,
86
+ controlUser: options.controlUser,
87
+ codexHomeDir: options.codexHomeDir,
88
+ });
89
+ }
90
+
91
+ async function readJsonResponse(response) {
92
+ const text = await response.text();
93
+ try {
94
+ return text ? JSON.parse(text) : {};
95
+ } catch {
96
+ return {};
97
+ }
98
+ }
99
+
100
+ function buildClaimFailureMessage(error, claimUrl) {
101
+ const lines = [trim(error?.message, 'license claim failed')];
102
+ lines.push('');
103
+ lines.push('The node remains installed but activation is still pending.');
104
+ lines.push(`Retry with: codexbot activate --license-key '<your-license-key>' --license-claim-url '${claimUrl}'`);
105
+ lines.push('Or complete activation manually after obtaining an activation package.');
106
+ return lines.join('\n');
107
+ }
108
+
109
+ export function createNpmDistributionActivationService(deps = {}) {
110
+ const fsPromises = deps.fsPromises || fs;
111
+ const fsSync = deps.fsSync || fsSyncDefault;
112
+ const osModule = deps.osModule || os;
113
+ const pathModule = deps.pathModule || path;
114
+ const spawnFn = deps.spawnFn || spawn;
115
+ const processImpl = deps.processImpl || process;
116
+ const fetchFn = deps.fetchFn || fetch;
117
+ const logger = deps.logger || console;
118
+ const promptFactory = deps.promptFactory || createPrompter;
119
+
120
+ async function resolveLicenseKey(options = {}) {
121
+ const explicit = trim(options.licenseKey);
122
+ if (explicit) return explicit;
123
+ const prompter = promptFactory({
124
+ stdin: processImpl.stdin,
125
+ stdout: processImpl.stdout,
126
+ });
127
+ try {
128
+ if (Boolean(options.nonInteractive) || !prompter.interactive) {
129
+ throw new Error('license key is required');
130
+ }
131
+ const entered = trim(await prompter.ask('License key: '));
132
+ if (!entered) throw new Error('license key is required');
133
+ return entered;
134
+ } finally {
135
+ await prompter.close().catch(() => {});
136
+ }
137
+ }
138
+
139
+ async function readInstalledNodeIdentity(layout, options = {}) {
140
+ const scriptPath = pathModule.join(layout.appDir, 'scripts', 'codexbot_node_identity.sh');
141
+ const isRoot = typeof processImpl.getuid === 'function' ? processImpl.getuid() === 0 : false;
142
+ const command = isRoot ? 'bash' : 'sudo';
143
+ const commandArgs = isRoot
144
+ ? [scriptPath, '--control-env-file', layout.controlEnvFile]
145
+ : ['bash', scriptPath, '--control-env-file', layout.controlEnvFile];
146
+ const result = await runChildCapture(spawnFn, command, commandArgs);
147
+ if (result.code !== 0) {
148
+ throw new Error(trim(result.stderr || result.stdout, 'failed to read node identity'));
149
+ }
150
+ let payload = {};
151
+ try {
152
+ payload = JSON.parse(result.stdout);
153
+ } catch {
154
+ throw new Error('node identity output is not valid JSON');
155
+ }
156
+ return payload;
157
+ }
158
+
159
+ async function fetchActivationPackage({ claimUrl, licenseKey, identity }) {
160
+ const response = await fetchFn(claimUrl, {
161
+ method: 'POST',
162
+ headers: {
163
+ 'content-type': 'application/json',
164
+ 'user-agent': 'codexbot-npm-cli/1.0',
165
+ },
166
+ body: JSON.stringify({
167
+ licenseKey,
168
+ fingerprint: trim(identity?.machineFingerprint),
169
+ node: {
170
+ id: trim(identity?.nodeId),
171
+ ...(identity?.payload && typeof identity.payload === 'object' && !Array.isArray(identity.payload) ? identity.payload : {}),
172
+ },
173
+ }),
174
+ });
175
+ const payload = await readJsonResponse(response);
176
+ if (!response.ok) {
177
+ const err = new Error(trim(payload?.error?.message || payload?.message, `license claim http ${response.status}`));
178
+ err.code = trim(payload?.error?.code || payload?.code, `HTTP_${response.status}`);
179
+ err.statusCode = response.status;
180
+ err.payload = payload;
181
+ throw err;
182
+ }
183
+ return payload;
184
+ }
185
+
186
+ async function writeActivationPackage(tempDir, activation = {}) {
187
+ const licenseFile = pathModule.join(tempDir, 'license.json');
188
+ const licensePublicKeyFile = pathModule.join(tempDir, 'license-public.pem');
189
+ const licenseServerPublicKeyFile = pathModule.join(tempDir, 'license-server-public.pem');
190
+ await fsPromises.writeFile(licenseFile, `${JSON.stringify(activation.license || {}, null, 2)}\n`, 'utf8');
191
+ await fsPromises.writeFile(licensePublicKeyFile, `${trim(activation.licensePublicKey)}\n`, 'utf8');
192
+ await fsPromises.writeFile(licenseServerPublicKeyFile, `${trim(activation.licenseServerPublicKey)}\n`, 'utf8');
193
+ return {
194
+ licenseFile,
195
+ licensePublicKeyFile,
196
+ licenseServerPublicKeyFile,
197
+ };
198
+ }
199
+
200
+ async function runLocalActivationScript(layout, files) {
201
+ const scriptPath = pathModule.join(layout.appDir, 'scripts', 'codexbot_activate.sh');
202
+ const isRoot = typeof processImpl.getuid === 'function' ? processImpl.getuid() === 0 : false;
203
+ const baseArgs = [
204
+ scriptPath,
205
+ '--control-env-file', layout.controlEnvFile,
206
+ '--license-file', files.licenseFile,
207
+ '--license-public-key-file', files.licensePublicKeyFile,
208
+ '--license-server-public-key-file', files.licenseServerPublicKeyFile,
209
+ '--start',
210
+ ];
211
+ const command = isRoot ? 'bash' : 'sudo';
212
+ const commandArgs = isRoot ? baseArgs : ['bash', ...baseArgs];
213
+ return runChildInherit(spawnFn, command, commandArgs);
214
+ }
215
+
216
+ async function claimAndActivate(options = {}) {
217
+ const defaults = getDefaultDistributionConfig(processImpl.env);
218
+ const licenseKey = await resolveLicenseKey(options);
219
+ const claimUrl = buildInstallLicenseClaimUrl({
220
+ installBaseUrl: options.installBaseUrl || defaults.installBaseUrl,
221
+ claimUrl: options.licenseClaimUrl || defaults.licenseClaimUrl,
222
+ allowHttp: Boolean(options.allowHttp),
223
+ });
224
+ const layout = resolveControlLayout({
225
+ fsSync,
226
+ pathModule,
227
+ processImpl,
228
+ options,
229
+ });
230
+ const tempDir = await fsPromises.mkdtemp(pathModule.join(osModule.tmpdir(), 'codexbot-activation-'));
231
+ try {
232
+ logger.log('==> Read node identity');
233
+ const identity = await readInstalledNodeIdentity(layout, options);
234
+ logger.log(`==> Claim activation package from ${claimUrl}`);
235
+ const claim = await fetchActivationPackage({ claimUrl, licenseKey, identity });
236
+ const activation = claim?.activation || {};
237
+ if (!activation.license || !trim(activation.licensePublicKey) || !trim(activation.licenseServerPublicKey)) {
238
+ throw new Error('claim response is missing activation package files');
239
+ }
240
+ const files = await writeActivationPackage(tempDir, activation);
241
+ logger.log('==> Activate installed node');
242
+ const exitCode = await runLocalActivationScript(layout, files);
243
+ if (exitCode !== 0) {
244
+ throw new Error(`activation script exited with code ${exitCode}`);
245
+ }
246
+ return {
247
+ ok: true,
248
+ claim,
249
+ };
250
+ } catch (error) {
251
+ const wrapped = new Error(buildClaimFailureMessage(error, claimUrl));
252
+ wrapped.cause = error;
253
+ throw wrapped;
254
+ } finally {
255
+ await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
256
+ }
257
+ }
258
+
259
+ return {
260
+ claimAndActivate,
261
+ };
262
+ }
@@ -12,6 +12,7 @@ function readOptionValue(argv, index, name) {
12
12
  function parseBootstrapOptions(argv = []) {
13
13
  const options = {
14
14
  channel: '',
15
+ installBaseUrl: '',
15
16
  updateBaseUrl: '',
16
17
  manifestUrl: '',
17
18
  publicKeyUrl: '',
@@ -20,6 +21,23 @@ function parseBootstrapOptions(argv = []) {
20
21
  token: '',
21
22
  allowHttp: false,
22
23
  noSignature: false,
24
+ nonInteractive: false,
25
+ connector: '',
26
+ telegramBotToken: '',
27
+ telegramChatIds: '',
28
+ wecomBotId: '',
29
+ wecomSecret: '',
30
+ wecomTencentDocsApiKey: '',
31
+ openAiApiKey: '',
32
+ deferLicenseActivation: false,
33
+ licenseKey: '',
34
+ licenseClaimUrl: '',
35
+ licenseFile: '',
36
+ licensePublicKeyFile: '',
37
+ licenseServerPublicKeyFile: '',
38
+ seedEnvFile: '',
39
+ seedConnectorsStateFile: '',
40
+ seedConnectorsCredentialsFile: '',
23
41
  forwardedArgs: [],
24
42
  };
25
43
 
@@ -31,6 +49,11 @@ function parseBootstrapOptions(argv = []) {
31
49
  i += 1;
32
50
  continue;
33
51
  }
52
+ if (token === '--install-base-url') {
53
+ options.installBaseUrl = readOptionValue(argv, i, token);
54
+ i += 1;
55
+ continue;
56
+ }
34
57
  if (token === '--update-base-url') {
35
58
  options.updateBaseUrl = readOptionValue(argv, i, token);
36
59
  i += 1;
@@ -66,6 +89,89 @@ function parseBootstrapOptions(argv = []) {
66
89
  options.forwardedArgs.push(token);
67
90
  continue;
68
91
  }
92
+ if (token === '--non-interactive' || token === '--no-prompt') {
93
+ options.nonInteractive = true;
94
+ continue;
95
+ }
96
+ if (token === '--connector') {
97
+ options.connector = readOptionValue(argv, i, token);
98
+ i += 1;
99
+ continue;
100
+ }
101
+ if (token === '--telegram-bot-token') {
102
+ options.telegramBotToken = readOptionValue(argv, i, token);
103
+ i += 1;
104
+ continue;
105
+ }
106
+ if (token === '--telegram-chat-ids') {
107
+ options.telegramChatIds = readOptionValue(argv, i, token);
108
+ i += 1;
109
+ continue;
110
+ }
111
+ if (token === '--wecom-bot-id') {
112
+ options.wecomBotId = readOptionValue(argv, i, token);
113
+ i += 1;
114
+ continue;
115
+ }
116
+ if (token === '--wecom-secret') {
117
+ options.wecomSecret = readOptionValue(argv, i, token);
118
+ i += 1;
119
+ continue;
120
+ }
121
+ if (token === '--wecom-tencent-docs-api-key') {
122
+ options.wecomTencentDocsApiKey = readOptionValue(argv, i, token);
123
+ i += 1;
124
+ continue;
125
+ }
126
+ if (token === '--openai-api-key') {
127
+ options.openAiApiKey = readOptionValue(argv, i, token);
128
+ i += 1;
129
+ continue;
130
+ }
131
+ if (token === '--defer-license-activation') {
132
+ options.deferLicenseActivation = true;
133
+ continue;
134
+ }
135
+ if (token === '--license-key') {
136
+ options.licenseKey = readOptionValue(argv, i, token);
137
+ i += 1;
138
+ continue;
139
+ }
140
+ if (token === '--license-claim-url') {
141
+ options.licenseClaimUrl = readOptionValue(argv, i, token);
142
+ i += 1;
143
+ continue;
144
+ }
145
+ if (token === '--license-file') {
146
+ options.licenseFile = readOptionValue(argv, i, token);
147
+ i += 1;
148
+ continue;
149
+ }
150
+ if (token === '--license-public-key-file') {
151
+ options.licensePublicKeyFile = readOptionValue(argv, i, token);
152
+ i += 1;
153
+ continue;
154
+ }
155
+ if (token === '--license-server-public-key-file') {
156
+ options.licenseServerPublicKeyFile = readOptionValue(argv, i, token);
157
+ i += 1;
158
+ continue;
159
+ }
160
+ if (token === '--seed-env-file') {
161
+ options.seedEnvFile = readOptionValue(argv, i, token);
162
+ i += 1;
163
+ continue;
164
+ }
165
+ if (token === '--seed-connectors-state-file') {
166
+ options.seedConnectorsStateFile = readOptionValue(argv, i, token);
167
+ i += 1;
168
+ continue;
169
+ }
170
+ if (token === '--seed-connectors-credentials-file') {
171
+ options.seedConnectorsCredentialsFile = readOptionValue(argv, i, token);
172
+ i += 1;
173
+ continue;
174
+ }
69
175
  if (token === '--no-signature') {
70
176
  options.noSignature = true;
71
177
  options.forwardedArgs.push(token);
@@ -77,7 +183,7 @@ function parseBootstrapOptions(argv = []) {
77
183
  return options;
78
184
  }
79
185
 
80
- function parseAuthOptions(argv = []) {
186
+ function parseLayoutOptions(argv = []) {
81
187
  const options = {
82
188
  layoutProfile: '',
83
189
  appDir: '',
@@ -118,6 +224,71 @@ function parseAuthOptions(argv = []) {
118
224
  return options;
119
225
  }
120
226
 
227
+ function parseActivationOptions(argv = []) {
228
+ const options = {
229
+ ...parseLayoutOptions([]),
230
+ installBaseUrl: '',
231
+ licenseClaimUrl: '',
232
+ allowHttp: false,
233
+ nonInteractive: false,
234
+ licenseKey: '',
235
+ };
236
+ for (let i = 0; i < argv.length; i += 1) {
237
+ const token = trim(argv[i]);
238
+ if (!token) continue;
239
+ if (token === '--license-key') {
240
+ options.licenseKey = readOptionValue(argv, i, token);
241
+ i += 1;
242
+ continue;
243
+ }
244
+ if (token === '--install-base-url') {
245
+ options.installBaseUrl = readOptionValue(argv, i, token);
246
+ i += 1;
247
+ continue;
248
+ }
249
+ if (token === '--license-claim-url') {
250
+ options.licenseClaimUrl = readOptionValue(argv, i, token);
251
+ i += 1;
252
+ continue;
253
+ }
254
+ if (token === '--allow-http') {
255
+ options.allowHttp = true;
256
+ continue;
257
+ }
258
+ if (token === '--non-interactive' || token === '--no-prompt') {
259
+ options.nonInteractive = true;
260
+ continue;
261
+ }
262
+ if (token === '--layout-profile') {
263
+ options.layoutProfile = readOptionValue(argv, i, token);
264
+ i += 1;
265
+ continue;
266
+ }
267
+ if (token === '--app-dir') {
268
+ options.appDir = readOptionValue(argv, i, token);
269
+ i += 1;
270
+ continue;
271
+ }
272
+ if (token === '--control-env-file') {
273
+ options.controlEnvFile = readOptionValue(argv, i, token);
274
+ i += 1;
275
+ continue;
276
+ }
277
+ if (token === '--control-user') {
278
+ options.controlUser = readOptionValue(argv, i, token);
279
+ i += 1;
280
+ continue;
281
+ }
282
+ if (token === '--codex-home-dir') {
283
+ options.codexHomeDir = readOptionValue(argv, i, token);
284
+ i += 1;
285
+ continue;
286
+ }
287
+ throw new Error(`unknown activate arg: ${token}`);
288
+ }
289
+ return options;
290
+ }
291
+
121
292
  export function parseCliArgs(argv = []) {
122
293
  const args = Array.isArray(argv) ? argv.map((item) => String(item || '')) : [];
123
294
  const command = trim(args[0]).toLowerCase();
@@ -128,7 +299,7 @@ export function parseCliArgs(argv = []) {
128
299
  return { action: 'version' };
129
300
  }
130
301
  if (command === 'doctor') {
131
- return { action: 'doctor', options: parseAuthOptions(args.slice(1)) };
302
+ return { action: 'doctor', options: parseLayoutOptions(args.slice(1)) };
132
303
  }
133
304
  if (command === 'install') {
134
305
  return { action: 'install', options: parseBootstrapOptions(args.slice(1)) };
@@ -136,12 +307,15 @@ export function parseCliArgs(argv = []) {
136
307
  if (command === 'upgrade') {
137
308
  return { action: 'upgrade', options: parseBootstrapOptions(args.slice(1)) };
138
309
  }
310
+ if (command === 'activate') {
311
+ return { action: 'activate', options: parseActivationOptions(args.slice(1)) };
312
+ }
139
313
  if (command === 'auth') {
140
314
  const sub = trim(args[1]).toLowerCase() || 'status';
141
315
  if (!['status', 'login', 'login-link', 'logout'].includes(sub)) {
142
316
  throw new Error(`unknown auth subcommand: ${sub}`);
143
317
  }
144
- return { action: 'auth', mode: sub, options: parseAuthOptions(args.slice(2)) };
318
+ return { action: 'auth', mode: sub, options: parseLayoutOptions(args.slice(2)) };
145
319
  }
146
320
  throw new Error(`unknown command: ${command}`);
147
321
  }
@@ -1,3 +1,4 @@
1
+ const DEFAULT_INSTALL_BASE_URL = 'https://codexbotinstall.afffun.com';
1
2
  const DEFAULT_UPDATE_BASE_URL = 'https://codexbotupdate.afffun.com';
2
3
  const DEFAULT_CHANNEL = 'stable';
3
4
 
@@ -28,9 +29,59 @@ function assertHttps(value, { allowHttp = false, label = 'url' } = {}) {
28
29
 
29
30
  export function getDefaultDistributionConfig(env = process.env) {
30
31
  return {
32
+ installBaseUrl: normalizeBaseUrl(env.CODEXBOT_INSTALL_BASE_URL, DEFAULT_INSTALL_BASE_URL),
31
33
  updateBaseUrl: normalizeBaseUrl(env.CODEXBOT_UPDATE_BASE_URL, DEFAULT_UPDATE_BASE_URL),
32
34
  channel: normalizeChannel(env.CODEXBOT_INSTALL_CHANNEL, DEFAULT_CHANNEL),
33
35
  authToken: trim(env.CODEXBOT_UPDATE_TOKEN || env.CODEX_REMOTE_UPDATE_AUTH_TOKEN, ''),
36
+ licenseClaimUrl: trim(env.CODEXBOT_LICENSE_CLAIM_URL, ''),
37
+ };
38
+ }
39
+
40
+ export function buildInstallLicenseClaimUrl({
41
+ installBaseUrl = DEFAULT_INSTALL_BASE_URL,
42
+ claimUrl = '',
43
+ allowHttp = false,
44
+ } = {}) {
45
+ const resolvedInstallBaseUrl = normalizeBaseUrl(installBaseUrl, DEFAULT_INSTALL_BASE_URL);
46
+ const resolvedClaimUrl = trim(claimUrl) || `${resolvedInstallBaseUrl}/v1/install/license/claim`;
47
+ return assertHttps(resolvedClaimUrl, { allowHttp, label: 'license claim url' });
48
+ }
49
+
50
+ export function buildPublicInstallUrls({
51
+ installBaseUrl = DEFAULT_INSTALL_BASE_URL,
52
+ updateBaseUrl = DEFAULT_UPDATE_BASE_URL,
53
+ channel = DEFAULT_CHANNEL,
54
+ manifestUrl = '',
55
+ publicKeyUrl = '',
56
+ installRemoteUrl = '',
57
+ runtimeManifestUrl = '',
58
+ packageDownloadBaseUrl = '',
59
+ allowHttp = false,
60
+ } = {}) {
61
+ const resolvedInstallBaseUrl = normalizeBaseUrl(installBaseUrl, DEFAULT_INSTALL_BASE_URL);
62
+ const resolvedUpdateBaseUrl = normalizeBaseUrl(updateBaseUrl, DEFAULT_UPDATE_BASE_URL);
63
+ const resolvedChannel = normalizeChannel(channel, DEFAULT_CHANNEL);
64
+ const publicRoot = `${resolvedInstallBaseUrl}/v1/install/${resolvedChannel}`;
65
+ const updateRoot = `${resolvedUpdateBaseUrl}/v1/channels/${resolvedChannel}`;
66
+ const urls = {
67
+ installBaseUrl: resolvedInstallBaseUrl,
68
+ updateBaseUrl: resolvedUpdateBaseUrl,
69
+ channel: resolvedChannel,
70
+ manifestUrl: trim(manifestUrl) || `${publicRoot}/manifest.json`,
71
+ publicKeyUrl: trim(publicKeyUrl) || `${publicRoot}/packages/latest/update-public.pem`,
72
+ installRemoteUrl: trim(installRemoteUrl) || `${publicRoot}/packages/latest/install-remote.sh`,
73
+ runtimeManifestUrl: trim(runtimeManifestUrl) || `${updateRoot}/manifest.json`,
74
+ packageDownloadBaseUrl: trim(packageDownloadBaseUrl) || `${publicRoot}/packages`,
75
+ };
76
+ return {
77
+ installBaseUrl: assertHttps(urls.installBaseUrl, { allowHttp, label: 'install base url' }),
78
+ updateBaseUrl: assertHttps(urls.updateBaseUrl, { allowHttp, label: 'update base url' }),
79
+ channel: urls.channel,
80
+ manifestUrl: assertHttps(urls.manifestUrl, { allowHttp, label: 'manifest url' }),
81
+ publicKeyUrl: assertHttps(urls.publicKeyUrl, { allowHttp, label: 'public key url' }),
82
+ installRemoteUrl: assertHttps(urls.installRemoteUrl, { allowHttp, label: 'install-remote url' }),
83
+ runtimeManifestUrl: assertHttps(urls.runtimeManifestUrl, { allowHttp, label: 'runtime manifest url' }),
84
+ packageDownloadBaseUrl: assertHttps(urls.packageDownloadBaseUrl, { allowHttp, label: 'package download base url' }),
34
85
  };
35
86
  }
36
87
 
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseCliArgs } from './args_service.mjs';
4
+ import { createNpmDistributionActivationService } from './activation_service.mjs';
4
5
  import { createNpmDistributionInstallService } from './install_service.mjs';
5
6
  import { createNpmDistributionAuthService } from './auth_service.mjs';
6
7
 
@@ -16,18 +17,43 @@ function renderUsage() {
16
17
  'Usage:',
17
18
  ' codexbot install [bootstrap options] [install-remote flags...]',
18
19
  ' codexbot upgrade [bootstrap options] [install-remote flags...]',
20
+ ' codexbot activate [activation options]',
19
21
  ' codexbot auth status|login|login-link|logout [layout overrides]',
20
22
  ' codexbot doctor [layout overrides]',
21
23
  ' codexbot version',
22
24
  '',
23
25
  'Bootstrap options:',
24
26
  ' --channel <stable|canary|...>',
27
+ ' --install-base-url <https://codexbotinstall.example.com>',
25
28
  ' --update-base-url <https://codexbotupdate.example.com>',
29
+ ' install defaults to an interactive seed wizard unless --non-interactive is used.',
30
+ ' --connector <telegram|wecom|skip>',
31
+ ' --telegram-bot-token <token>',
32
+ ' --telegram-chat-ids <id1,id2,...>',
33
+ ' --wecom-bot-id <botId>',
34
+ ' --wecom-secret <secret>',
35
+ ' --wecom-tencent-docs-api-key <apiKey>',
36
+ ' --openai-api-key <key>',
37
+ ' --license-key <key>',
38
+ ' install will auto-claim and activate when a license key is provided.',
39
+ ' --license-file </path/to/license.json>',
40
+ ' --license-public-key-file </path/to/license-public.pem>',
41
+ ' --license-server-public-key-file </path/to/license-server-public.pem>',
42
+ ' --defer-license-activation',
43
+ ' --non-interactive',
44
+ ' advanced seed flags such as --seed-env-file remain supported for automation',
26
45
  ' --manifest-url <url>',
27
46
  ' --public-key-url <url>',
28
47
  ' --install-remote-url <url>',
29
48
  ' --public-key-file </path/to/update-public.pem>',
30
- ' --token <update server token>',
49
+ ' --token <protected update server token>',
50
+ ' --allow-http',
51
+ '',
52
+ 'Activation options:',
53
+ ' --license-key <key>',
54
+ ' --install-base-url <https://codexbotinstall.example.com>',
55
+ ' --license-claim-url <https://codexbotinstall.example.com/v1/install/license/claim>',
56
+ ' --non-interactive',
31
57
  ' --allow-http',
32
58
  '',
33
59
  'Layout overrides:',
@@ -42,6 +68,7 @@ function renderUsage() {
42
68
  export async function runCodexbotCli(argv = [], deps = {}) {
43
69
  const logger = deps.logger || console;
44
70
  const installService = deps.installService || createNpmDistributionInstallService({ logger });
71
+ const activationService = deps.activationService || createNpmDistributionActivationService({ logger });
45
72
  const authService = deps.authService || createNpmDistributionAuthService({ logger });
46
73
  const cli = parseCliArgs(argv);
47
74
  if (cli.action === 'help') {
@@ -71,8 +98,18 @@ export async function runCodexbotCli(argv = [], deps = {}) {
71
98
  if (cli.action === 'upgrade') {
72
99
  return installService.runUpgradeCommand(cli.options || {});
73
100
  }
101
+ if (cli.action === 'activate') {
102
+ await activationService.claimAndActivate(cli.options || {});
103
+ return 0;
104
+ }
74
105
  if (cli.action === 'auth') {
75
106
  return authService.runAuthCommand(cli.mode, cli.options || {});
76
107
  }
77
108
  throw new Error(`unsupported action: ${cli.action}`);
78
109
  }
110
+
111
+ export function formatCliError(err) {
112
+ const message = String(err?.message || err || '').trim();
113
+ if (!message) return 'codexbot failed';
114
+ return message;
115
+ }
@@ -0,0 +1,417 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline/promises';
5
+
6
+ function trim(value, fallback = '') {
7
+ const text = String(value ?? '').trim();
8
+ return text || String(fallback ?? '').trim();
9
+ }
10
+
11
+ function normalizeConnectorChoice(value) {
12
+ const raw = trim(value).toLowerCase();
13
+ if (raw === 'telegram') return 'telegram';
14
+ if (raw === 'wecom') return 'wecom';
15
+ if (raw === 'skip' || raw === 'none') return 'skip';
16
+ return '';
17
+ }
18
+
19
+ function normalizeList(input) {
20
+ return Array.from(new Set(
21
+ String(input || '')
22
+ .split(/[,\n]/)
23
+ .map((item) => trim(item))
24
+ .filter(Boolean),
25
+ ));
26
+ }
27
+
28
+ function normalizeYesNo(input, fallback = false) {
29
+ const raw = trim(input).toLowerCase();
30
+ if (!raw) return Boolean(fallback);
31
+ if (['y', 'yes', '1', 'true'].includes(raw)) return true;
32
+ if (['n', 'no', '0', 'false'].includes(raw)) return false;
33
+ return Boolean(fallback);
34
+ }
35
+
36
+ function buildConnectorCapabilities(type) {
37
+ if (type === 'telegram') {
38
+ return {
39
+ supportsEdit: true,
40
+ supportsButtons: true,
41
+ supportsAudioIn: true,
42
+ supportsAudioOut: true,
43
+ supportsFileUpload: true,
44
+ supportsStreaming: true,
45
+ supportsWebSocket: false,
46
+ supportsWebhook: true,
47
+ supportsPolling: true,
48
+ supportsOAuth: false,
49
+ };
50
+ }
51
+ if (type === 'wecom') {
52
+ return {
53
+ supportsEdit: false,
54
+ supportsButtons: false,
55
+ supportsAudioIn: true,
56
+ supportsAudioOut: false,
57
+ supportsFileUpload: true,
58
+ supportsStreaming: true,
59
+ supportsWebSocket: true,
60
+ supportsWebhook: false,
61
+ supportsPolling: false,
62
+ supportsOAuth: false,
63
+ };
64
+ }
65
+ return {};
66
+ }
67
+
68
+ function buildTelegramSeed(nowIso, {
69
+ botToken,
70
+ allowedChatIds,
71
+ baseUrl = 'https://api.telegram.org',
72
+ fileBaseUrl = 'https://api.telegram.org/file',
73
+ } = {}) {
74
+ const connectorId = 'telegram-primary';
75
+ const configured = Boolean(trim(botToken) && allowedChatIds.length > 0);
76
+ const updatedAt = nowIso();
77
+ return {
78
+ connectorId,
79
+ descriptor: {
80
+ connectorId,
81
+ type: 'telegram',
82
+ name: connectorId,
83
+ displayName: 'Telegram',
84
+ source: 'managed',
85
+ managed: true,
86
+ status: configured ? 'authorized' : 'draft',
87
+ mode: 'polling',
88
+ capabilities: buildConnectorCapabilities('telegram'),
89
+ config: {
90
+ baseUrl,
91
+ fileBaseUrl,
92
+ },
93
+ binding: {
94
+ allowedChats: allowedChatIds,
95
+ },
96
+ runtime: {
97
+ desiredEnabled: true,
98
+ configured,
99
+ authModes: ['bot_token'],
100
+ },
101
+ health: {
102
+ state: configured ? 'idle' : 'misconfigured',
103
+ lastError: configured ? '' : 'missing telegram bot token or allowed chats',
104
+ },
105
+ auth: {
106
+ supportedModes: ['bot_token'],
107
+ mode: configured ? 'bot_token' : '',
108
+ state: configured ? 'configured' : 'unconfigured',
109
+ configured,
110
+ credentialRef: `cred_${connectorId}`,
111
+ storedKeys: configured ? ['botToken'] : [],
112
+ redirectUri: '',
113
+ authorizeUrl: '',
114
+ scopes: [],
115
+ tokenExchangeConfigured: false,
116
+ hasAccessToken: false,
117
+ hasRefreshToken: false,
118
+ lastAuthorizedAt: configured ? updatedAt : '',
119
+ lastError: configured ? '' : 'missing telegram bot token or allowed chats',
120
+ lastErrorAt: configured ? '' : updatedAt,
121
+ },
122
+ createdAt: updatedAt,
123
+ updatedAt,
124
+ },
125
+ credentials: {
126
+ connectorId,
127
+ credentialRef: `cred_${connectorId}`,
128
+ authMode: 'bot_token',
129
+ values: configured ? { botToken: trim(botToken) } : {},
130
+ oauth: {},
131
+ updatedAt,
132
+ },
133
+ };
134
+ }
135
+
136
+ function buildWecomSeed(nowIso, {
137
+ botId,
138
+ secret,
139
+ tencentDocsApiKey = '',
140
+ websocketUrl = 'wss://openws.work.weixin.qq.com',
141
+ } = {}) {
142
+ const connectorId = 'wecom-primary';
143
+ const configured = Boolean(trim(botId) && trim(secret));
144
+ const updatedAt = nowIso();
145
+ const storedKeys = [];
146
+ if (trim(botId)) storedKeys.push('botId');
147
+ if (trim(secret)) storedKeys.push('secret');
148
+ if (trim(tencentDocsApiKey)) storedKeys.push('tencentDocsApiKey');
149
+ return {
150
+ connectorId,
151
+ descriptor: {
152
+ connectorId,
153
+ type: 'wecom',
154
+ name: connectorId,
155
+ displayName: 'WeCom',
156
+ source: 'managed',
157
+ managed: true,
158
+ status: configured ? 'authorized' : 'draft',
159
+ mode: 'websocket',
160
+ capabilities: buildConnectorCapabilities('wecom'),
161
+ config: {
162
+ botId: trim(botId),
163
+ websocketUrl,
164
+ dmPolicy: 'open',
165
+ groupPolicy: 'open',
166
+ sendThinkingMessage: false,
167
+ tencentDocsEnabled: Boolean(trim(tencentDocsApiKey)),
168
+ },
169
+ binding: {},
170
+ runtime: {
171
+ desiredEnabled: true,
172
+ configured,
173
+ authModes: ['bot_secret'],
174
+ },
175
+ health: {
176
+ state: configured ? 'idle' : 'misconfigured',
177
+ lastError: configured ? '' : 'missing fields: botId, secret',
178
+ },
179
+ auth: {
180
+ supportedModes: ['bot_secret'],
181
+ mode: configured ? 'bot_secret' : '',
182
+ state: configured ? 'configured' : 'unconfigured',
183
+ configured,
184
+ credentialRef: `cred_${connectorId}`,
185
+ storedKeys,
186
+ redirectUri: '',
187
+ authorizeUrl: '',
188
+ scopes: [],
189
+ tokenExchangeConfigured: false,
190
+ hasAccessToken: false,
191
+ hasRefreshToken: false,
192
+ lastAuthorizedAt: configured ? updatedAt : '',
193
+ lastError: configured ? '' : 'missing fields: botId, secret',
194
+ lastErrorAt: configured ? '' : updatedAt,
195
+ },
196
+ createdAt: updatedAt,
197
+ updatedAt,
198
+ },
199
+ credentials: {
200
+ connectorId,
201
+ credentialRef: `cred_${connectorId}`,
202
+ authMode: 'bot_secret',
203
+ values: {
204
+ ...(trim(botId) ? { botId: trim(botId) } : {}),
205
+ ...(trim(secret) ? { secret: trim(secret) } : {}),
206
+ ...(trim(tencentDocsApiKey) ? { tencentDocsApiKey: trim(tencentDocsApiKey) } : {}),
207
+ },
208
+ oauth: {},
209
+ updatedAt,
210
+ },
211
+ };
212
+ }
213
+
214
+ function createPrompter({ stdin, stdout } = {}) {
215
+ const input = stdin || process.stdin;
216
+ const output = stdout || process.stdout;
217
+ if (!input?.isTTY || !output?.isTTY) {
218
+ return {
219
+ interactive: false,
220
+ ask: async () => '',
221
+ close: async () => {},
222
+ };
223
+ }
224
+ const rl = readline.createInterface({ input, output });
225
+ return {
226
+ interactive: true,
227
+ ask: async (prompt) => trim(await rl.question(prompt)),
228
+ close: async () => {
229
+ rl.close();
230
+ },
231
+ };
232
+ }
233
+
234
+ async function ensureReadableFile(fsPromises, filePath, label) {
235
+ const target = trim(filePath);
236
+ if (!target) return '';
237
+ try {
238
+ await fsPromises.access(target);
239
+ } catch {
240
+ throw new Error(`${label} not found: ${target}`);
241
+ }
242
+ return target;
243
+ }
244
+
245
+ function renderEnvFile(values = {}) {
246
+ const lines = Object.entries(values)
247
+ .filter(([, value]) => trim(value))
248
+ .map(([key, value]) => `${key}=${String(value)}`);
249
+ return lines.length ? `${lines.join('\n')}\n` : '';
250
+ }
251
+
252
+ export function createNpmDistributionInstallSeedService(deps = {}) {
253
+ const fsPromises = deps.fsPromises || fs;
254
+ const osModule = deps.osModule || os;
255
+ const pathModule = deps.pathModule || path;
256
+ const processImpl = deps.processImpl || process;
257
+ const logger = deps.logger || console;
258
+ const nowIso = deps.nowIso || (() => new Date().toISOString());
259
+ const promptFactory = deps.promptFactory || createPrompter;
260
+
261
+ async function prepareInstallSeed(options = {}) {
262
+ const explicitSeedFiles = Boolean(
263
+ trim(options.seedEnvFile)
264
+ || trim(options.seedConnectorsStateFile)
265
+ || trim(options.seedConnectorsCredentialsFile),
266
+ );
267
+ const prompter = promptFactory({
268
+ stdin: processImpl.stdin,
269
+ stdout: processImpl.stdout,
270
+ });
271
+ const nonInteractive = Boolean(options.nonInteractive) || !prompter.interactive;
272
+ const tempDir = await fsPromises.mkdtemp(pathModule.join(osModule.tmpdir(), 'codexbot-install-seed-'));
273
+ const cleanup = async () => {
274
+ await prompter.close().catch(() => {});
275
+ await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
276
+ };
277
+
278
+ try {
279
+ const result = {
280
+ seedEnvFile: trim(options.seedEnvFile),
281
+ seedConnectorsStateFile: trim(options.seedConnectorsStateFile),
282
+ seedConnectorsCredentialsFile: trim(options.seedConnectorsCredentialsFile),
283
+ seedLicenseFile: '',
284
+ seedLicensePublicKeyFile: '',
285
+ seedLicenseServerPublicKeyFile: '',
286
+ licenseKey: trim(options.licenseKey),
287
+ deferLicenseActivation: Boolean(options.deferLicenseActivation),
288
+ summary: {
289
+ connector: 'skip',
290
+ connectorSeeded: false,
291
+ openAiSeeded: false,
292
+ licenseDeferred: false,
293
+ },
294
+ cleanup,
295
+ };
296
+
297
+ if (explicitSeedFiles) {
298
+ if (result.licenseKey) {
299
+ throw new Error('license key cannot be combined with explicit license seed files');
300
+ }
301
+ result.seedLicenseFile = trim(options.licenseFile);
302
+ result.seedLicensePublicKeyFile = trim(options.licensePublicKeyFile);
303
+ result.seedLicenseServerPublicKeyFile = trim(options.licenseServerPublicKeyFile);
304
+ if (!result.deferLicenseActivation
305
+ && !result.seedLicenseFile
306
+ && !result.seedLicensePublicKeyFile
307
+ && !result.seedLicenseServerPublicKeyFile) {
308
+ result.deferLicenseActivation = true;
309
+ }
310
+ result.summary.licenseDeferred = result.deferLicenseActivation;
311
+ return result;
312
+ }
313
+
314
+ let connector = normalizeConnectorChoice(options.connector);
315
+ const hasExplicitConnectorArgs = Boolean(
316
+ connector
317
+ || trim(options.telegramBotToken)
318
+ || trim(options.telegramChatIds)
319
+ || trim(options.wecomBotId)
320
+ || trim(options.wecomSecret)
321
+ || trim(options.wecomTencentDocsApiKey),
322
+ );
323
+ if (!connector && hasExplicitConnectorArgs) {
324
+ connector = trim(options.wecomBotId || options.wecomSecret || options.wecomTencentDocsApiKey) ? 'wecom' : 'telegram';
325
+ }
326
+ if (!connector && !nonInteractive) {
327
+ logger.log('==> Remote control channel setup');
328
+ connector = normalizeConnectorChoice(await prompter.ask('Choose connector [telegram/wecom/skip] (default: skip): ')) || 'skip';
329
+ }
330
+ if (!connector) connector = 'skip';
331
+
332
+ const openAiApiKey = trim(options.openAiApiKey || (!nonInteractive ? await prompter.ask('OPENAI_API_KEY (optional, blank to skip): ') : ''));
333
+ if (openAiApiKey) {
334
+ const envFile = pathModule.join(tempDir, 'seed.env');
335
+ await fsPromises.writeFile(envFile, renderEnvFile({ OPENAI_API_KEY: openAiApiKey }), 'utf8');
336
+ result.seedEnvFile = envFile;
337
+ result.summary.openAiSeeded = true;
338
+ }
339
+
340
+ if (connector === 'telegram') {
341
+ const telegramBotToken = trim(options.telegramBotToken || (!nonInteractive ? await prompter.ask('Telegram bot token: ') : ''));
342
+ const telegramChatIds = normalizeList(options.telegramChatIds || (!nonInteractive ? await prompter.ask('Telegram allowed chat IDs (comma-separated): ') : ''));
343
+ if (!telegramBotToken || telegramChatIds.length === 0) {
344
+ throw new Error('Telegram connector requires bot token and at least one allowed chat ID');
345
+ }
346
+ const seed = buildTelegramSeed(nowIso, {
347
+ botToken: telegramBotToken,
348
+ allowedChatIds: telegramChatIds,
349
+ });
350
+ const stateFile = pathModule.join(tempDir, 'connectors.json');
351
+ const credentialsFile = pathModule.join(tempDir, 'connectors.credentials.json');
352
+ await fsPromises.writeFile(stateFile, `${JSON.stringify({ connectors: { [seed.connectorId]: seed.descriptor } }, null, 2)}\n`, 'utf8');
353
+ await fsPromises.writeFile(credentialsFile, `${JSON.stringify({ connectors: { [seed.connectorId]: seed.credentials } }, null, 2)}\n`, 'utf8');
354
+ result.seedConnectorsStateFile = stateFile;
355
+ result.seedConnectorsCredentialsFile = credentialsFile;
356
+ result.summary.connector = 'telegram';
357
+ result.summary.connectorSeeded = true;
358
+ } else if (connector === 'wecom') {
359
+ const wecomBotId = trim(options.wecomBotId || (!nonInteractive ? await prompter.ask('WeCom bot ID: ') : ''));
360
+ const wecomSecret = trim(options.wecomSecret || (!nonInteractive ? await prompter.ask('WeCom bot secret: ') : ''));
361
+ const wecomTencentDocsApiKey = trim(options.wecomTencentDocsApiKey || (!nonInteractive ? await prompter.ask('Tencent Docs API key (optional, blank to skip): ') : ''));
362
+ if (!wecomBotId || !wecomSecret) {
363
+ throw new Error('WeCom connector requires bot ID and secret');
364
+ }
365
+ const seed = buildWecomSeed(nowIso, {
366
+ botId: wecomBotId,
367
+ secret: wecomSecret,
368
+ tencentDocsApiKey: wecomTencentDocsApiKey,
369
+ });
370
+ const stateFile = pathModule.join(tempDir, 'connectors.json');
371
+ const credentialsFile = pathModule.join(tempDir, 'connectors.credentials.json');
372
+ await fsPromises.writeFile(stateFile, `${JSON.stringify({ connectors: { [seed.connectorId]: seed.descriptor } }, null, 2)}\n`, 'utf8');
373
+ await fsPromises.writeFile(credentialsFile, `${JSON.stringify({ connectors: { [seed.connectorId]: seed.credentials } }, null, 2)}\n`, 'utf8');
374
+ result.seedConnectorsStateFile = stateFile;
375
+ result.seedConnectorsCredentialsFile = credentialsFile;
376
+ result.summary.connector = 'wecom';
377
+ result.summary.connectorSeeded = true;
378
+ }
379
+
380
+ let licenseKey = result.licenseKey;
381
+ let licenseFile = trim(options.licenseFile);
382
+ let licensePublicKeyFile = trim(options.licensePublicKeyFile);
383
+ let licenseServerPublicKeyFile = trim(options.licenseServerPublicKeyFile);
384
+ if (licenseKey && (licenseFile || licensePublicKeyFile || licenseServerPublicKeyFile)) {
385
+ throw new Error('license key cannot be combined with direct license file inputs');
386
+ }
387
+ if (!licenseKey && !licenseFile && !licensePublicKeyFile && !licenseServerPublicKeyFile && !nonInteractive) {
388
+ licenseKey = trim(await prompter.ask('License key (optional, blank to skip and activate later): '));
389
+ }
390
+ if (!licenseKey && !licenseFile && !licensePublicKeyFile && !licenseServerPublicKeyFile) {
391
+ result.deferLicenseActivation = true;
392
+ }
393
+
394
+ if (licenseKey) {
395
+ result.licenseKey = licenseKey;
396
+ result.deferLicenseActivation = true;
397
+ }
398
+ const licenseProvided = Boolean(licenseFile || licensePublicKeyFile || licenseServerPublicKeyFile);
399
+ if (licenseProvided) {
400
+ result.seedLicenseFile = await ensureReadableFile(fsPromises, licenseFile, 'license file');
401
+ result.seedLicensePublicKeyFile = await ensureReadableFile(fsPromises, licensePublicKeyFile, 'license public key file');
402
+ result.seedLicenseServerPublicKeyFile = await ensureReadableFile(fsPromises, licenseServerPublicKeyFile, 'license server public key file');
403
+ result.deferLicenseActivation = false;
404
+ }
405
+
406
+ result.summary.licenseDeferred = result.deferLicenseActivation;
407
+ return result;
408
+ } catch (error) {
409
+ await cleanup();
410
+ throw error;
411
+ }
412
+ }
413
+
414
+ return {
415
+ prepareInstallSeed,
416
+ };
417
+ }
@@ -2,16 +2,80 @@ import fs from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
- import { buildRemoteInstallUrls, getDefaultDistributionConfig } from './config_service.mjs';
5
+ import {
6
+ buildPublicInstallUrls,
7
+ buildRemoteInstallUrls,
8
+ getDefaultDistributionConfig,
9
+ } from './config_service.mjs';
10
+ import { createNpmDistributionActivationService } from './activation_service.mjs';
11
+ import { createNpmDistributionInstallSeedService } from './install_seed_service.mjs';
6
12
 
7
13
  function trim(value, fallback = '') {
8
14
  const text = String(value ?? '').trim();
9
15
  return text || String(fallback ?? '').trim();
10
16
  }
11
17
 
12
- async function ensureOkResponse(response, label) {
18
+ function buildAuthFailureMessage({
19
+ label,
20
+ status,
21
+ statusText,
22
+ installBaseUrl,
23
+ channel,
24
+ updateBaseUrl,
25
+ authTokenPresent,
26
+ distributionMode = 'upgrade',
27
+ } = {}) {
28
+ const lines = [
29
+ `${label} download failed: ${status} ${statusText}`,
30
+ ];
31
+ if (status === 401 || status === 403) {
32
+ lines.push('');
33
+ if (distributionMode === 'install') {
34
+ lines.push('The public Codexbot install facade rejected this request or could not proxy it upstream.');
35
+ lines.push('Retry with one of the following:');
36
+ lines.push(`- codexbot install --channel ${channel || 'stable'}`);
37
+ lines.push(`- codexbot install --install-base-url '${installBaseUrl || 'https://codexbotinstall.afffun.com'}' --update-base-url '${updateBaseUrl || 'https://codexbotupdate.afffun.com'}'`);
38
+ lines.push('- Verify the install facade is configured with upstream access to the protected update server.');
39
+ if (authTokenPresent) {
40
+ lines.push('- A token was provided, but public install should normally not require it; verify the install facade is not pointing at a protected route.');
41
+ }
42
+ } else {
43
+ lines.push('The Codexbot npm package is public, but the protected update channel still requires an update token.');
44
+ lines.push('Retry with one of the following:');
45
+ lines.push(`- codexbot upgrade --channel ${channel || 'stable'} --token '<UPDATE_SERVER_AUTH_TOKEN>'`);
46
+ lines.push(`- export CODEXBOT_UPDATE_TOKEN='<UPDATE_SERVER_AUTH_TOKEN>' && codexbot upgrade --channel ${channel || 'stable'}`);
47
+ lines.push(`- target update base: ${updateBaseUrl || 'https://codexbotupdate.afffun.com'}`);
48
+ if (authTokenPresent) {
49
+ lines.push('- A token was provided, but the server rejected it. Check that the token is valid for this update channel.');
50
+ } else {
51
+ lines.push('- No update token was provided for this request.');
52
+ }
53
+ }
54
+ }
55
+ return lines.join('\n');
56
+ }
57
+
58
+ async function ensureOkResponse(response, {
59
+ label,
60
+ installBaseUrl,
61
+ channel,
62
+ updateBaseUrl,
63
+ authTokenPresent = false,
64
+ distributionMode = 'upgrade',
65
+ } = {}) {
13
66
  if (!response.ok) {
14
- throw new Error(`${label} download failed: ${response.status} ${response.statusText}`);
67
+ const error = new Error(buildAuthFailureMessage({
68
+ label,
69
+ status: response.status,
70
+ statusText: response.statusText,
71
+ installBaseUrl,
72
+ channel,
73
+ updateBaseUrl,
74
+ authTokenPresent,
75
+ distributionMode,
76
+ }));
77
+ error.code = `HTTP_${response.status}`;
78
+ throw error;
15
79
  }
16
80
  return response;
17
81
  }
@@ -46,53 +110,132 @@ export function createNpmDistributionInstallService(deps = {}) {
46
110
  const spawnFn = deps.spawnFn || spawn;
47
111
  const processImpl = deps.processImpl || process;
48
112
  const logger = deps.logger || console;
113
+ const installSeedService = deps.installSeedService || createNpmDistributionInstallSeedService({
114
+ fsPromises,
115
+ osModule,
116
+ pathModule,
117
+ processImpl,
118
+ logger,
119
+ });
120
+ const activationService = deps.activationService || createNpmDistributionActivationService({
121
+ fsPromises,
122
+ pathModule,
123
+ spawnFn,
124
+ processImpl,
125
+ fetchFn,
126
+ logger,
127
+ });
49
128
 
50
129
  async function runRemoteInstall({
51
130
  mode,
52
131
  options = {},
53
132
  } = {}) {
54
133
  const defaults = getDefaultDistributionConfig(processImpl.env);
55
- const urls = buildRemoteInstallUrls({
56
- updateBaseUrl: options.updateBaseUrl || defaults.updateBaseUrl,
57
- channel: options.channel || defaults.channel,
58
- manifestUrl: options.manifestUrl,
59
- publicKeyUrl: options.publicKeyUrl,
60
- installRemoteUrl: options.installRemoteUrl,
61
- allowHttp: Boolean(options.allowHttp),
62
- });
63
- const authToken = trim(options.token, defaults.authToken);
134
+ const distributionMode = trim(mode, 'auto') === 'install' ? 'install' : 'upgrade';
135
+ const urls = distributionMode === 'install'
136
+ ? buildPublicInstallUrls({
137
+ installBaseUrl: options.installBaseUrl || defaults.installBaseUrl,
138
+ updateBaseUrl: options.updateBaseUrl || defaults.updateBaseUrl,
139
+ channel: options.channel || defaults.channel,
140
+ manifestUrl: options.manifestUrl,
141
+ publicKeyUrl: options.publicKeyUrl,
142
+ installRemoteUrl: options.installRemoteUrl,
143
+ allowHttp: Boolean(options.allowHttp),
144
+ })
145
+ : buildRemoteInstallUrls({
146
+ updateBaseUrl: options.updateBaseUrl || defaults.updateBaseUrl,
147
+ channel: options.channel || defaults.channel,
148
+ manifestUrl: options.manifestUrl,
149
+ publicKeyUrl: options.publicKeyUrl,
150
+ installRemoteUrl: options.installRemoteUrl,
151
+ allowHttp: Boolean(options.allowHttp),
152
+ });
153
+ let installSeed = null;
154
+ const effectiveOptions = { ...options };
155
+ if (distributionMode === 'install') {
156
+ installSeed = await installSeedService.prepareInstallSeed(options);
157
+ effectiveOptions.seedEnvFile = trim(installSeed.seedEnvFile, effectiveOptions.seedEnvFile);
158
+ effectiveOptions.seedConnectorsStateFile = trim(installSeed.seedConnectorsStateFile, effectiveOptions.seedConnectorsStateFile);
159
+ effectiveOptions.seedConnectorsCredentialsFile = trim(installSeed.seedConnectorsCredentialsFile, effectiveOptions.seedConnectorsCredentialsFile);
160
+ effectiveOptions.licenseFile = trim(installSeed.seedLicenseFile, effectiveOptions.licenseFile);
161
+ effectiveOptions.licensePublicKeyFile = trim(installSeed.seedLicensePublicKeyFile, effectiveOptions.licensePublicKeyFile);
162
+ effectiveOptions.licenseServerPublicKeyFile = trim(installSeed.seedLicenseServerPublicKeyFile, effectiveOptions.licenseServerPublicKeyFile);
163
+ effectiveOptions.licenseKey = trim(installSeed.licenseKey, effectiveOptions.licenseKey);
164
+ effectiveOptions.deferLicenseActivation = Boolean(installSeed.deferLicenseActivation || effectiveOptions.deferLicenseActivation);
165
+ }
166
+ const authToken = trim(effectiveOptions.token, defaults.authToken);
64
167
  const tempDir = await fsPromises.mkdtemp(pathModule.join(osModule.tmpdir(), 'codexbot-npm-'));
65
168
  const tempInstallPath = pathModule.join(tempDir, 'install-remote.sh');
66
169
  const tempKeyPath = pathModule.join(tempDir, 'update-public.pem');
67
170
  const headers = authToken ? { Authorization: `Bearer ${authToken}` } : {};
68
171
  try {
69
172
  logger.log(`==> Fetch install entry from ${urls.installRemoteUrl}`);
70
- const installResponse = await ensureOkResponse(await fetchFn(urls.installRemoteUrl, { headers }), 'install-remote');
173
+ const installResponse = await ensureOkResponse(await fetchFn(urls.installRemoteUrl, { headers }), {
174
+ label: 'install-remote',
175
+ installBaseUrl: urls.installBaseUrl,
176
+ channel: urls.channel,
177
+ updateBaseUrl: urls.updateBaseUrl,
178
+ authTokenPresent: Boolean(authToken),
179
+ distributionMode,
180
+ });
71
181
  await writeResponseToFile(installResponse, tempInstallPath, fsPromises);
72
182
  await fsPromises.chmod(tempInstallPath, 0o755);
73
183
 
74
- const publicKeyFile = trim(options.publicKeyFile, '');
184
+ const publicKeyFile = trim(effectiveOptions.publicKeyFile, '');
75
185
  const resolvedPublicKeyFile = publicKeyFile || tempKeyPath;
76
186
  if (!publicKeyFile) {
77
187
  logger.log(`==> Fetch update public key from ${urls.publicKeyUrl}`);
78
- const keyResponse = await ensureOkResponse(await fetchFn(urls.publicKeyUrl, { headers }), 'update public key');
188
+ const keyResponse = await ensureOkResponse(await fetchFn(urls.publicKeyUrl, { headers }), {
189
+ label: 'update public key',
190
+ installBaseUrl: urls.installBaseUrl,
191
+ channel: urls.channel,
192
+ updateBaseUrl: urls.updateBaseUrl,
193
+ authTokenPresent: Boolean(authToken),
194
+ distributionMode,
195
+ });
79
196
  await writeResponseToFile(keyResponse, tempKeyPath, fsPromises);
80
197
  }
81
198
 
82
199
  const args = [
83
200
  tempInstallPath,
84
201
  '--manifest-url', urls.manifestUrl,
202
+ ...(distributionMode === 'install'
203
+ ? ['--runtime-remote-update-manifest-url', urls.runtimeManifestUrl]
204
+ : []),
205
+ ...(distributionMode === 'install'
206
+ ? ['--package-download-base-url', urls.packageDownloadBaseUrl]
207
+ : []),
85
208
  '--public-key-file', resolvedPublicKeyFile,
86
209
  '--channel', urls.channel,
87
210
  '--mode', trim(mode, 'auto'),
88
211
  ...(authToken ? ['--auth-token', authToken] : []),
89
- ...(Array.isArray(options.forwardedArgs) ? options.forwardedArgs : []),
212
+ ...(effectiveOptions.deferLicenseActivation ? ['--defer-license-activation'] : []),
213
+ ...(trim(effectiveOptions.seedEnvFile) ? ['--seed-env-file', trim(effectiveOptions.seedEnvFile)] : []),
214
+ ...(trim(effectiveOptions.seedConnectorsStateFile) ? ['--seed-connectors-state-file', trim(effectiveOptions.seedConnectorsStateFile)] : []),
215
+ ...(trim(effectiveOptions.seedConnectorsCredentialsFile) ? ['--seed-connectors-credentials-file', trim(effectiveOptions.seedConnectorsCredentialsFile)] : []),
216
+ ...(trim(effectiveOptions.licenseFile) ? ['--seed-license-file', trim(effectiveOptions.licenseFile)] : []),
217
+ ...(trim(effectiveOptions.licensePublicKeyFile) ? ['--seed-license-public-key-file', trim(effectiveOptions.licensePublicKeyFile)] : []),
218
+ ...(trim(effectiveOptions.licenseServerPublicKeyFile) ? ['--seed-license-server-public-key-file', trim(effectiveOptions.licenseServerPublicKeyFile)] : []),
219
+ ...(Array.isArray(effectiveOptions.forwardedArgs) ? effectiveOptions.forwardedArgs : []),
90
220
  ];
91
221
  const isRoot = typeof processImpl.getuid === 'function' ? processImpl.getuid() === 0 : false;
92
222
  const command = isRoot ? 'bash' : 'sudo';
93
223
  const commandArgs = isRoot ? args : ['bash', ...args];
94
- return await runChild(spawnFn, command, commandArgs);
224
+ const exitCode = await runChild(spawnFn, command, commandArgs);
225
+ if (distributionMode === 'install' && exitCode === 0 && trim(effectiveOptions.licenseKey)) {
226
+ await activationService.claimAndActivate({
227
+ licenseKey: effectiveOptions.licenseKey,
228
+ installBaseUrl: urls.installBaseUrl,
229
+ licenseClaimUrl: effectiveOptions.licenseClaimUrl,
230
+ allowHttp: Boolean(effectiveOptions.allowHttp),
231
+ nonInteractive: true,
232
+ });
233
+ }
234
+ return exitCode;
95
235
  } finally {
236
+ if (installSeed?.cleanup) {
237
+ await installSeed.cleanup().catch(() => {});
238
+ }
96
239
  await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
97
240
  }
98
241
  }