@walkhi/code-relax 0.1.0-beta.31 → 0.1.0-beta.33

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.
@@ -7,11 +7,12 @@ import { readAndroidCatalog } from '../src/self-relay/android-releases.mjs';
7
7
  import { readPublicManifest, updatePublicAndroid } from '../src/self-relay/public-releases.mjs';
8
8
 
9
9
  // This administrative command runs only on the deployment host; no keys cross SSH.
10
- const [apkFile, metadataFile] = process.argv.slice(2);
10
+ const [apkFile, metadataFile, mode] = process.argv.slice(2);
11
+ if (mode && mode !== '--existing-object') throw new Error('Invalid publication mode');
11
12
  if (!apkFile || !metadataFile) throw new Error('Usage: publish-android-release.mjs APK METADATA');
12
13
  const storage = androidReleaseStorage('/etc/codex-relay/android-releases.json', '/etc/codex-relay/tencent-monitor.json');
13
14
  const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
14
- if (metadata.packageName !== 'cn.walkhi.coderelax' || !/^[a-f0-9]{64}$/.test(metadata.certificateSha256)) throw new Error('Invalid APK identity');
15
+ if (metadata.packageName !== 'com.walkhi.codexremote' || !/^[a-f0-9]{64}$/.test(metadata.certificateSha256)) throw new Error('Invalid APK identity');
15
16
  const { certificateSha256: expectedCertificate } = JSON.parse(fs.readFileSync('/etc/codex-relay/android-releases.json', 'utf8'));
16
17
  if (!/^[a-f0-9]{64}$/.test(expectedCertificate) || metadata.certificateSha256 !== expectedCertificate) throw new Error('APK signing certificate does not match release configuration');
17
18
  const bytes = fs.readFileSync(apkFile), sha256 = createHash('sha256').update(bytes).digest('hex');
@@ -30,7 +31,7 @@ try {
30
31
  const next = { latestVersionCode: versionCode, releases: [...catalog.releases.filter(item => item.versionCode !== versionCode), release] };
31
32
  fs.writeFileSync(temporary, JSON.stringify(next, null, 2) + '\n', { mode: 0o640 });
32
33
  readAndroidCatalog(temporary);
33
- if (!previous) {
34
+ if (!previous && mode !== '--existing-object') {
34
35
  await new Promise((resolve, reject) => storage.cos.putObject({ ...storage.object(release), Body: bytes,
35
36
  ContentLength: bytes.length, ContentType: 'application/vnd.android.package-archive',
36
37
  ContentDisposition: `attachment; filename="Code-Relax-${versionName}.apk"`
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { androidReleaseStorage } from '../relay-admin/android-release-storage.mjs';
6
+ import { readHarmonyConfig, hapName, validateHarmonyMetadata, checkHarmonyVersion,
7
+ verifyHarmony, activateHarmony } from '../relay-admin/harmony-release.mjs';
8
+
9
+ // Local PowerShell releases use the same verifier, COS objects and activation as GitHub CD.
10
+ const directory = path.resolve(process.argv[2] || '');
11
+ if (!process.argv[2]) throw Error('Usage: publish-harmony-release.mjs DIRECTORY');
12
+ let stage = 'metadata';
13
+ try {
14
+ const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json5'), 'utf8')).app;
15
+ const config = readHarmonyConfig();
16
+ const metadata = { packageName: manifest.bundleName, versionCode: manifest.versionCode,
17
+ versionName: manifest.versionName, certificateSha256: config.certificateSha256 };
18
+ if (!Number.isSafeInteger(metadata.versionCode) || metadata.versionCode <= 0
19
+ || !/^\d+\.\d+\.\d+$/.test(metadata.versionName)) throw Error();
20
+ metadata.files = [hapName(metadata), 'manifest.json5', 'icon.png'].map(name => {
21
+ const bytes = fs.readFileSync(path.join(directory, name));
22
+ return { name, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'),
23
+ contentMd5: createHash('md5').update(bytes).digest('base64') };
24
+ });
25
+ Object.assign(metadata, { size: metadata.files[0].size, sha256: metadata.files[0].sha256,
26
+ contentMd5: metadata.files[0].contentMd5 });
27
+ validateHarmonyMetadata(metadata, config);
28
+ checkHarmonyVersion(metadata);
29
+ stage = 'signature';
30
+ await verifyHarmony(directory, metadata, config);
31
+ const storage = androidReleaseStorage('/etc/codex-relay/android-releases.json', '/etc/codex-relay/tencent-monitor.json');
32
+ const keys = metadata.files.map(f => `code-relax/harmony/${metadata.versionCode}/${f.name}`);
33
+ stage = 'upload';
34
+ for (const [i, file] of metadata.files.entries()) {
35
+ await new Promise((resolve, reject) => storage.cos.putObject({ ...storage.object({ objectKey: keys[i] }),
36
+ Body: fs.createReadStream(path.join(directory, file.name)), ContentLength: file.size,
37
+ ContentMD5: file.contentMd5, ContentType: i === 1 ? 'application/json' : i === 2 ? 'image/png' : 'application/octet-stream',
38
+ Headers: { 'x-cos-forbid-overwrite': 'true' }
39
+ }, (error, result) => error ? reject(error) : resolve(result)));
40
+ }
41
+ stage = 'publication';
42
+ console.log(JSON.stringify(await activateHarmony(metadata, storage, keys)));
43
+ } catch {
44
+ console.error(`Harmony publication failed at ${stage}; inspect objects and current version before retrying.`);
45
+ process.exitCode = 1;
46
+ }
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import http from 'node:http';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { Transform, Readable } from 'node:stream';
9
+ import { execFile } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { verifyReleaseIdentity, createUploadSigner } from '../relay-admin/release-oidc.mjs';
13
+ import { androidReleaseStorage } from '../relay-admin/android-release-storage.mjs';
14
+ import { readAndroidCatalog } from '../src/self-relay/android-releases.mjs';
15
+
16
+ import { readHarmonyConfig, validateHarmonyMetadata, checkHarmonyVersion, verifyHarmony, activateHarmony } from '../relay-admin/harmony-release.mjs';
17
+
18
+ const execute = promisify(execFile);
19
+ const config = JSON.parse(fs.readFileSync('/etc/codex-relay/android-releases.json', 'utf8'));
20
+ const credential = JSON.parse(fs.readFileSync('/etc/codex-relay/tencent-monitor.json', 'utf8'));
21
+ const storage = androidReleaseStorage('/etc/codex-relay/android-releases.json', '/etc/codex-relay/tencent-monitor.json');
22
+ const signUpload = createUploadSigner(config, { secretId: credential.secretId, secretKey: credential.secretKey });
23
+ const stateDir = '/var/lib/codex-relay/release-publisher';
24
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
25
+ const cosCall = (method, args) => new Promise((resolve, reject) => storage.cos[method](args, (e, r) => e ? reject(e) : resolve(r)));
26
+ const object = key => ({ Bucket: config.bucket, Region: config.region, Key: key });
27
+ const identityKey = p => `${p.repository_id}/${p.run_id}/${p.run_attempt}/${p.sha}`;
28
+ const filename = id => path.join(stateDir, id + '.json');
29
+ function save(row) {
30
+ const temp = filename(row.intentId) + '.' + randomUUID();
31
+ fs.writeFileSync(temp, JSON.stringify(row), { mode: 0o600 }); fs.renameSync(temp, filename(row.intentId));
32
+ }
33
+ const read = id => fs.existsSync(filename(id)) ? JSON.parse(fs.readFileSync(filename(id))) : null;
34
+ const publicState = row => ({ ok: true, intentId: row.intentId, status: row.status, result: row.result, error: row.error });
35
+ async function readJson(req) {
36
+ const chunks = []; let size = 0;
37
+ for await (const chunk of req) { size += chunk.length; if (size > 16384) throw Error('Request too large'); chunks.push(chunk); }
38
+ return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
39
+ }
40
+ function metadataFrom(data, claims, target) {
41
+ const { packageName, versionCode, versionName, size, sha256, contentMd5, certificateSha256, notes,
42
+ buildCommit, artifactId, runId, sourceCommit, verifyOnly } = data;
43
+ if (!Number.isSafeInteger(versionCode) || versionCode < 1 || versionCode > 2100000000
44
+ || !/^[a-f0-9]{64}$/.test(sha256)
45
+ || !Number.isSafeInteger(size) || size < 1 || size > 200 * 1024 * 1024
46
+ || typeof notes !== 'string' || notes.length > 2000 || !/^[A-Za-z0-9+/]{22}==$/.test(contentMd5)
47
+ || !/^\d+$/.test(artifactId) || runId !== claims.run_id || sourceCommit !== claims.sha
48
+ || typeof verifyOnly !== 'boolean' || !/^[a-f0-9]{40}$/.test(buildCommit)) throw Error('Invalid metadata');
49
+ if (target === 'hap') validateHarmonyMetadata(data, readHarmonyConfig());
50
+ else if (packageName !== 'com.walkhi.codexremote' || !/^\d+\.\d+\.\d+\.\d+$/.test(versionName)
51
+ || !versionName.endsWith('.' + versionCode) || certificateSha256 !== config.certificateSha256) throw Error('Invalid Android identity');
52
+ return { target, ...(target === 'hap' ? { files: data.files } : {}), packageName, versionCode, versionName, size, sha256, contentMd5, certificateSha256, notes,
53
+ buildCommit, artifactId, runId, sourceCommit, verifyOnly };
54
+ }
55
+ let busy = false;
56
+ const server = http.createServer(async (req, res) => {
57
+ const reply = (code, data) => { if (!res.destroyed) { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(data)); } };
58
+ let claims;
59
+ try { claims = await verifyReleaseIdentity((req.headers.authorization || '').replace(/^Bearer /, '')); }
60
+ catch { reply(401, { ok: false, error: 'OIDC_REJECTED' }); return; }
61
+ const target = /^\/code-relax\/publish\/(android|hap)\/intents(?:\/|$)/.exec(req.url || '')?.[1];
62
+ if (!target) { reply(404, { ok: false, error: 'NOT_FOUND' }); return; }
63
+ const prefix = `/code-relax/publish/${target}/intents`;
64
+ try {
65
+ if (req.method === 'POST' && req.url === prefix) {
66
+ let metadata;
67
+ try { metadata = metadataFrom(await readJson(req), claims, target); } catch { reply(400, { ok: false, error: 'INVALID_RELEASE' }); return; }
68
+ if (!metadata.verifyOnly && target === 'hap') {
69
+ try { checkHarmonyVersion(metadata); } catch { reply(409, { ok: false, error: 'VERSION_CONFLICT' }); return; }
70
+ }
71
+ if (!metadata.verifyOnly && target === 'android') {
72
+ const catalog = readAndroidCatalog(config.catalogFile);
73
+ const previous = catalog.releases.find(x => x.versionCode === metadata.versionCode);
74
+ if (catalog.latestVersionCode > metadata.versionCode || (previous && previous.sha256 !== metadata.sha256)) {
75
+ reply(409, { ok: false, error: 'VERSION_CONFLICT' }); return;
76
+ }
77
+ }
78
+ const identity = identityKey(claims);
79
+ const intentId = createHash('sha256').update(JSON.stringify({ identity, metadata })).digest('hex');
80
+ let row = read(intentId);
81
+ if (row && row.status !== 'prepared') { reply(200, publicState(row)); return; }
82
+ const key = metadata.verifyOnly ? `code-relax/android/ci-verification/${claims.run_id}/${intentId}.apk`
83
+ : `code-relax/android/${metadata.versionCode}/Code-Relax-${metadata.sha256.slice(0, 16)}.apk`;
84
+ const keys = target === 'hap' ? metadata.files.map(f => metadata.verifyOnly
85
+ ? `code-relax/harmony/ci-verification/${claims.run_id}/${intentId}/${f.name}`
86
+ : `code-relax/harmony/${metadata.versionCode}/${f.name}`) : [key];
87
+ row ??= { intentId, identity, metadata, key, keys, status: 'prepared' };
88
+ save(row);
89
+ const grant = target === 'hap' ? { uploads: await Promise.all(metadata.files.map(async (f, i) => ({ name: f.name,
90
+ ...await signUpload(keys[i], f, i === 1 ? 'application/json' : i === 2 ? 'image/png' : 'application/octet-stream') }))) }
91
+ : await signUpload(key, metadata);
92
+ reply(200, { ...publicState(row), ...grant }); return;
93
+ }
94
+ const match = new RegExp(`^${prefix}/([a-f0-9]{64})(/complete)?$`).exec(req.url || '');
95
+ if (!match || !((!match[2] && req.method === 'GET') || (match[2] && req.method === 'POST'))) {
96
+ reply(404, { ok: false, error: 'NOT_FOUND' }); return;
97
+ }
98
+ const row = read(match[1]);
99
+ if (!row || row.identity !== identityKey(claims) || (row.metadata.target || 'android') !== target) { reply(404, { ok: false, error: 'INTENT_NOT_FOUND' }); return; }
100
+ if (match[2] && row.status === 'prepared') {
101
+ if (busy) { reply(409, { ok: false, error: 'PUBLICATION_BUSY' }); return; }
102
+ busy = true; row.status = 'validating'; save(row);
103
+ void complete(row).finally(() => { busy = false; });
104
+ }
105
+ reply(row.status === 'validating' ? 202 : 200, publicState(row));
106
+ } catch (error) {
107
+ console.error(JSON.stringify({ event: 'release-api-failed', code: /^[A-Za-z.]+$/.test(error.code || '') ? error.code : 'io-or-provider' }));
108
+ reply(503, { ok: false, error: 'RELEASE_UNAVAILABLE' });
109
+ }
110
+ });
111
+ async function complete(row) {
112
+ const { metadata, key } = row;
113
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-relax-release-'));
114
+ const apk = path.join(dir, 'release.apk'), json = path.join(dir, 'release.json');
115
+ const hap = metadata.target === 'hap';
116
+ const files = hap ? metadata.files : [{ ...metadata, name: 'release.apk' }];
117
+ let stage = 'download';
118
+ try {
119
+ for (const [i, file] of files.entries()) {
120
+ const ticket = await cosCall('getObjectUrl', { ...object(row.keys?.[i] || key), Sign: true, Method: 'GET', Protocol: 'https:', Expires: 300 });
121
+ const response = await fetch(ticket.Url, { signal: AbortSignal.timeout(120000) });
122
+ if (!response.ok || Number(response.headers.get('content-length')) !== file.size) throw Error();
123
+ let size = 0; const hash = createHash('sha256'), md5 = createHash('md5');
124
+ await pipeline(Readable.fromWeb(response.body), new Transform({ transform(chunk, encoding, next) {
125
+ size += chunk.length; if (size > file.size) return next(Error('Oversize'));
126
+ hash.update(chunk); md5.update(chunk); next(null, chunk);
127
+ } }), fs.createWriteStream(path.join(dir, file.name), { flags: 'wx', mode: 0o600 }));
128
+ if (size !== file.size || hash.digest('hex') !== file.sha256 || md5.digest('base64') !== file.contentMd5) throw Error();
129
+ const unsigned = new URL(ticket.Url); unsigned.search = '';
130
+ if (!hap && (await fetch(unsigned, { method: 'HEAD', signal: AbortSignal.timeout(15000) })).status !== 403) throw Error();
131
+ }
132
+ if (hap) {
133
+ stage = 'harmony-signature';
134
+ await verifyHarmony(dir, metadata, readHarmonyConfig());
135
+ row.result = { sha256: metadata.sha256, versionCode: metadata.versionCode, size: metadata.size };
136
+ if (metadata.verifyOnly) row.status = 'verified';
137
+ else { stage = 'harmony-publish'; row.result = await activateHarmony(metadata, storage, row.keys); row.status = 'published'; }
138
+ save(row); return;
139
+ }
140
+ stage = 'signature';
141
+ const { stdout: signature } = await execute('/usr/bin/apksigner', ['verify', '--print-certs', apk], {
142
+ timeout: 30000, env: { ...process.env, JAVA_TOOL_OPTIONS: '-Xmx256m' } });
143
+ const certificates = [...signature.matchAll(/^Signer #\d+ certificate SHA-256 digest: ([a-f0-9]{64})$/gm)];
144
+ if (certificates.length !== 1 || certificates[0][1] !== metadata.certificateSha256) throw Error();
145
+ stage = 'identity';
146
+ const { stdout: badging } = await execute('/usr/bin/aapt', ['dump', 'badging', apk], { timeout: 30000 });
147
+ const identity = /^package: name='([^']+)' versionCode='(\d+)' versionName='([^']+)'/m.exec(badging);
148
+ if (!identity || identity[1] !== metadata.packageName || Number(identity[2]) !== metadata.versionCode
149
+ || identity[3] !== metadata.versionName || /^application-debuggable/m.test(badging)) throw Error();
150
+ row.result = { sha256: metadata.sha256, versionCode: metadata.versionCode, size: metadata.size };
151
+ if (metadata.verifyOnly) row.status = 'verified';
152
+ else {
153
+ stage = 'publish';
154
+ fs.writeFileSync(json, JSON.stringify(metadata), { mode: 0o600 });
155
+ const { stdout } = await execute(process.execPath, [fileURLToPath(new URL('./publish-android-release.mjs', import.meta.url)), apk, json, '--existing-object'], { timeout: 240000 });
156
+ row.result = JSON.parse(stdout.trim()); row.status = 'published';
157
+ }
158
+ save(row);
159
+ } catch (error) {
160
+ row.status = 'failed'; row.error = `RELEASE_${stage.toUpperCase()}_FAILED`; save(row);
161
+ console.error(JSON.stringify({ event: 'release-validation-failed', intentId: row.intentId, stage, code: /^[A-Za-z0-9_.-]+$/.test(error.code || '') ? error.code : 'validation' }));
162
+ } finally {
163
+ for (const file of [...files.map(f => path.join(dir, f.name)), json]) fs.rmSync(file, { force: true }); fs.rmdirSync(dir);
164
+ // Verification objects remain private evidence; the deployment identity has no DeleteObject grant.
165
+ }
166
+ }
167
+ // A restart never blindly launches an interrupted publisher a second time.
168
+ for (const name of fs.readdirSync(stateDir).filter(x => /^[a-f0-9]{64}\.json$/.test(x))) {
169
+ const row = read(name.slice(0, -5));
170
+ if (row.status === 'validating') { row.status = 'failed'; row.error = 'INTERRUPTED_CHECK_LATEST'; save(row); }
171
+ }
172
+ server.requestTimeout = 15000;
173
+ server.listen(45852, '127.0.0.1');
174
+ process.on('SIGTERM', () => { server.close(); const timer = setInterval(() => { if (!busy) { clearInterval(timer); process.exit(0); } }, 250); });
@@ -1 +1 @@
1
- {"layout":"npm-platform-packages","version":"0.1.0-beta.31"}
1
+ {"layout":"npm-platform-packages","version":"0.1.0-beta.33"}
@@ -42,7 +42,11 @@
42
42
  if (this.io.send(item.frames[item.index]) === false) throw new Error('文件直连发送失败');
43
43
  if (++item.index === item.frames.length) { this.outbound.shift(); this.queued -= item.size; }
44
44
  }
45
- } catch { this.close(1011); }
45
+ } catch { this.close(1011, 'send-failed'); }
46
+ }
47
+ waitForPartial() {
48
+ clearTimeout(this.timer);
49
+ this.timer = setTimeout(() => this.close(1008, 'partial-idle-timeout'), 30000);
46
50
  }
47
51
  receive(value) {
48
52
  if (this.readyState !== 1) return;
@@ -53,22 +57,22 @@
53
57
  if (frame.type !== 'rtc-file-bytes') { this.io.received?.(); this.emit('message', { data: value }); return; }
54
58
  if (!Number.isSafeInteger(frame.length) || frame.length < 1 || frame.length > MAX) throw new Error();
55
59
  this.io.received?.(); this.partial = { data: new Uint8Array(frame.length), offset: 0 };
56
- this.timer = setTimeout(() => this.close(1008), 30000); return;
60
+ this.waitForPartial(); return;
57
61
  }
58
62
  const bytes = value instanceof Uint8Array ? value : new Uint8Array(value), part = this.partial;
59
63
  if (!part || bytes.length !== Math.min(PART, part.data.length - part.offset)) throw new Error();
60
- this.io.received?.(); part.data.set(bytes, part.offset); part.offset += bytes.length;
64
+ this.waitForPartial(); this.io.received?.(); part.data.set(bytes, part.offset); part.offset += bytes.length;
61
65
  if (part.offset === part.data.length) {
62
66
  this.partial = null; clearTimeout(this.timer); this.timer = null;
63
67
  this.emit('message', { data: part.data.buffer });
64
68
  }
65
- } catch { this.close(1008); }
69
+ } catch { this.close(1008, 'invalid-frame'); }
66
70
  }
67
- close(code = 1000) {
71
+ close(code = 1000, reason = '') {
68
72
  if (this.readyState === 3) return;
69
73
  this.readyState = 3; clearTimeout(this.timer); this.timer = null;
70
74
  this.partial = null; this.outbound = []; this.queued = 0;
71
- this.io.close(); this.emit('close', { code });
75
+ this.io.close(); this.emit('close', { code, reason });
72
76
  }
73
77
  };
74
78
  });
@@ -36,5 +36,34 @@
36
36
  }
37
37
  return null;
38
38
  }
39
- return { parseQuestionReply, questionIds, fromNotification };
39
+ // A successful submission is enough to dismiss a question; model consumption
40
+ // can happen later. Store identities only, never answers or replayable writes.
41
+ function createAnswerStore({ storage, scope, onError }) {
42
+ const records = new Map();
43
+ const key = thread => 'codexRemoteAnsweredQuestions:' + JSON.stringify([
44
+ scope(), thread.hostId || 'local', thread.id,
45
+ ]);
46
+ function read(owner) {
47
+ if (!records.has(owner)) {
48
+ try {
49
+ const ids = JSON.parse(storage.getItem(owner) || '[]');
50
+ records.set(owner, new Set(Array.isArray(ids) ? ids.filter(id => typeof id === 'string') : []));
51
+ } catch (error) { records.set(owner, new Set()); onError?.(error); }
52
+ }
53
+ return records.get(owner);
54
+ }
55
+ function remember(owner, ids) {
56
+ const answered = read(owner);
57
+ let changed = false;
58
+ for (const id of ids) if (typeof id === 'string' && !answered.has(id)) {
59
+ answered.add(id); changed = true;
60
+ }
61
+ if (changed) {
62
+ try { storage.setItem(owner, JSON.stringify([...answered])); }
63
+ catch (error) { onError?.(error); }
64
+ }
65
+ }
66
+ return { key, has: (thread, id) => read(key(thread)).has(id), remember };
67
+ }
68
+ return { parseQuestionReply, questionIds, fromNotification, createAnswerStore };
40
69
  });
@@ -14,16 +14,34 @@ export function updatePublicAndroid(release, file = publicManifestFile) {
14
14
  || !/^\d+\.\d+\.\d+\.\d+$/.test(release.versionName)
15
15
  || !Number.isSafeInteger(release.size) || release.size <= 0
16
16
  || !/^[a-f0-9]{64}$/.test(release.sha256)) throw new Error('Invalid public Android release');
17
+ return updateMobileManifest('android', release, file);
18
+ }
19
+
20
+ export function updatePublicHarmony(release, file = publicManifestFile) {
21
+ if (!Number.isSafeInteger(release.versionCode) || !/^\d+\.\d+\.\d+$/.test(release.versionName)
22
+ || !/^[a-f0-9]{64}$/.test(release.sha256) || !Number.isSafeInteger(release.size) || release.size <= 0
23
+ || release.manifestUrl !== `https://download.coderelax.cn/code-relax/harmony/${release.versionCode}/manifest.json5`)
24
+ throw Error('Invalid public Harmony release');
25
+ return updateMobileManifest('harmonyos', release, file);
26
+ }
27
+
28
+ function updateMobileManifest(platform, release, file) {
17
29
  const lock = file + '.lock', temporary = file + '.new';
18
30
  const handle = fs.openSync(lock, 'wx', 0o600);
19
31
  try {
20
32
  const manifest = readPublicManifest(file);
21
- if (manifest.android.versionCode > release.versionCode) throw new Error('Public Android version would go backwards');
22
- manifest.android = { status: 'published', statusText: { zh: '', en: '' }, version: release.versionName,
33
+ if (manifest[platform].versionCode > release.versionCode) throw new Error('Public Android version would go backwards');
34
+ manifest[platform] = { status: 'published', statusText: { zh: '', en: '' }, version: release.versionName,
23
35
  versionCode: release.versionCode, size: release.size, sha256: release.sha256,
24
36
  url: `https://api.coderelax.cn/code-relax/android/download?versionCode=${release.versionCode}&redirect=1`, action: 'download' };
37
+ if (platform === 'harmonyos') {
38
+ manifest.harmonyos.statusText = { zh: '指定设备测试版', en: 'Registered-device test' };
39
+ manifest.harmonyos.url = 'https://www.coderelax.cn/code-relax/install';
40
+ manifest.harmonyos.manifestUrl = release.manifestUrl;
41
+ }
25
42
  const owner = fs.statSync(file);
26
43
  fs.writeFileSync(temporary, JSON.stringify(manifest, null, 2) + '\n', { mode: owner.mode & 0o777 });
44
+ fs.chmodSync(temporary, owner.mode & 0o777);
27
45
  if (process.platform !== 'win32') fs.chownSync(temporary, owner.uid, owner.gid);
28
46
  fs.renameSync(temporary, file);
29
47
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.31",
3
+ "version": "0.1.0-beta.33",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -41,8 +41,8 @@
41
41
  "tag": "beta"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.31",
45
- "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.31",
46
- "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.31"
44
+ "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.33",
45
+ "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.33",
46
+ "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.33"
47
47
  }
48
48
  }
@@ -53,14 +53,52 @@ export function formatSetupOutput(output) {
53
53
  try {
54
54
  const result = JSON.parse(output);
55
55
  const migration = result.managedAppServerMigration || {};
56
- return `Code Relax ${result.version || '未知版本'} 安装成功。\n`
56
+ return `Code Relax ${result.version || '未知版本'} 服务安装完成。\n`
57
57
  + '工作站服务和 Codex 内核已就绪,已启用登录自启。\n'
58
58
  + (migration.restarted ? 'Codex 内核已更新并重启;请检查安装前正在进行的任务。\n' : '')
59
- + (result.desktopRestartRecommended ? 'Codex Desktop:请完全退出后重新打开。\n' : '')
60
- + '生成配对二维码:relax pair\n';
59
+ + (result.desktopRestartRecommended ? 'Codex Desktop:请完全退出后重新打开。\n' : '');
61
60
  } catch { return output; }
62
61
  }
63
62
 
63
+ // npm owns command links. Inspect them without changing the user's shell configuration.
64
+ export function commandDiagnostics(entry, { prefix, env = process.env, platform = process.platform } = {}) {
65
+ const windows = platform === 'win32';
66
+ const bin = windows ? prefix : path.join(prefix, 'bin');
67
+ const command = path.join(bin, windows ? 'relax.cmd' : 'relax');
68
+ const lines = [`命令入口:${command}`];
69
+ let linkReady = false;
70
+ try {
71
+ if (windows) {
72
+ const shim = fs.readFileSync(command, 'utf8');
73
+ linkReady = shim.replaceAll('\\', '/').includes('node_modules/@walkhi/code-relax/dist/bin/codex-remote.mjs') && fs.existsSync(entry);
74
+ } else {
75
+ linkReady = fs.realpathSync(command) === fs.realpathSync(entry);
76
+ fs.accessSync(command, fs.constants.X_OK);
77
+ }
78
+ lines.push(linkReady ? '命令链接:正常' : '命令链接:目标与本次安装不一致');
79
+ } catch (error) {
80
+ linkReady = false;
81
+ lines.push(`命令链接:不可用(${error.code || error.message})`);
82
+ }
83
+ const normalize = value => {
84
+ let resolved;
85
+ try { resolved = fs.realpathSync(value); } catch { resolved = path.resolve(value); }
86
+ return windows ? resolved.toLowerCase() : resolved;
87
+ };
88
+ const pathValue = Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] || '';
89
+ const onPath = pathValue.split(windows ? ';' : ':').filter(Boolean)
90
+ .some(value => normalize(value.replace(/^"|"$/g, '')) === normalize(bin));
91
+ lines.push(`安装进程 PATH:${onPath ? '包含' : '未包含'}全局命令目录`);
92
+ lines.push('注:npm 会调整安装进程 PATH;当前终端能否直接找到命令仍以终端为准。');
93
+ if (!linkReady) lines.push('CLI 入口异常;服务状态请以上方健康检查为准,请保留本次诊断输出。');
94
+ else if (!onPath) lines.push('全局命令目录未在 PATH 中,可先使用下方完整路径命令。');
95
+ const quote = value => "'" + value.replaceAll("'", windows ? "''" : "'\"'\"'") + "'";
96
+ const direct = `${windows ? '& ' : ''}${quote(process.execPath)} ${quote(entry)}`;
97
+ lines.push(`无需依赖 relax 命令的检查入口${windows ? '(PowerShell)' : ''}:`, `${direct} doctor`);
98
+ lines.push('生成配对二维码:', `${direct} pair`);
99
+ return lines.join('\n') + '\n';
100
+ }
101
+
64
102
  function installationSnapshot() {
65
103
  const recordPath = path.join(serviceRoot, 'installation.json');
66
104
  if (!fs.existsSync(serviceRoot)) return { exists: false, valid: false };
@@ -113,8 +151,13 @@ if (invokedDirectly && process.env.npm_config_global === 'true') {
113
151
  prepareNativePackage(fileURLToPath(new URL('../', import.meta.url)));
114
152
  setupStarted = true;
115
153
  runStage(entry, 'setup', ['setup', '--json'], output => formatSetupOutput(output));
154
+ try { runStage(entry, 'doctor', ['doctor']); }
155
+ catch (error) {
156
+ console.error('服务安装已完成,但安装后健康检查未通过;请查看上方诊断与安装日志。');
157
+ process.exitCode = 1;
158
+ }
116
159
  console.log(`安装日志:${installLog}`);
117
- appendLog('[postinstall] success');
160
+ appendLog(process.exitCode ? '[postinstall] failed stage=doctor' : '[postinstall] success');
118
161
  } catch (error) {
119
162
  const current = installationSnapshot();
120
163
  if (!setupStarted) console.error(`平台组件检查失败:${error.message}`);
@@ -123,5 +166,16 @@ if (invokedDirectly && process.env.npm_config_global === 'true') {
123
166
  console.error(`安装日志:${installLog}`);
124
167
  appendLog('[postinstall] failed stage=setup');
125
168
  process.exitCode = Number.isInteger(error?.status) && error.status > 0 ? error.status : 1;
169
+ } finally {
170
+ try {
171
+ const prefix = execFileSync(process.execPath, [process.env.npm_execpath, 'prefix', '--global'],
172
+ { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
173
+ const output = commandDiagnostics(entry, { prefix });
174
+ process.stdout.write(output);
175
+ appendLog(`[command-diagnostics]\n${output}`);
176
+ } catch (error) {
177
+ console.error(`命令入口诊断失败:${error.message}`);
178
+ appendLog(`[command-diagnostics] failed: ${error.message}`);
179
+ }
126
180
  }
127
181
  }