Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,12 @@ Build and device runs target mobile platforms. iOS packaging is handled by CI.

```bash
git push
gh run watch
gh run download --name ios-ipa
gh run list --limit 10
gh run download <run-id> --name ios-ipa --dir build/artifacts/ios-ipa
```

Install the unsigned IPA with Sideloadly or AltStore.
For the agent workflow to verify Actions and download the IPA artifact locally,
see `docs/workflow.md`.

## Project Layout

Expand Down
44 changes: 37 additions & 7 deletions docs/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,48 @@ docker run --rm `
bash -c "flutter pub get && flutter analyze && flutter test"
```

## iOS CI 打包
## iOS IPA 工作流

iOS 打包由 GitHub Actions 处理,workflow 位于 `.github/workflows/ios.yml`。
iOS 打包由 GitHub Actions 处理。给 agent 的标准流程如下:

1. 确认本地分支已经推送,或 PR 已合并到 `main`。
2. 查看最近的 workflow run:

```powershell
gh run list --limit 10
```

3. 找到最新的 `main` / `push` run,确认这些 workflow 成功:
- `CI`
- `iOS unsigned IPA`
- `Build IPA`

4. 查看目标 run 的 artifact。优先使用 `iOS unsigned IPA` workflow 的 run id:

```powershell
gh api repos/botlong/remote-multi-agent/actions/runs/<run-id>/artifacts `
--jq '.artifacts[] | {name, expired, size_in_bytes, archive_download_url}'
```

5. 确认存在未过期的 `ios-ipa` artifact 后,下载到本地固定目录:

```powershell
New-Item -ItemType Directory -Force -Path "build\artifacts\ios-ipa" | Out-Null
gh run download <run-id> --name ios-ipa --dir "build\artifacts\ios-ipa"
Get-ChildItem -Path "build\artifacts\ios-ipa" -Force
```

如果只存在 `Build IPA` workflow 的 `ipa` artifact,下载到单独目录,避免和
`ios-ipa` 混在一起:

```powershell
git push
gh run list --limit 3
gh run watch <run-id>
gh run download <run-id> --name ios-ipa
New-Item -ItemType Directory -Force -Path "build\artifacts\ipa" | Out-Null
gh run download <run-id> --name ipa --dir "build\artifacts\ipa"
Get-ChildItem -Path "build\artifacts\ipa" -Force
```

下载后的 unsigned IPA 可通过 Sideloadly 或 AltStore 安装到 iPhone。
6. 最后向用户报告本地目录、IPA 文件名、run id 和 artifact 名称。不要提交 IPA;
`.gitignore` 已忽略 `/build/` 和 `*.ipa`。

## 常用命令

Expand Down
76 changes: 53 additions & 23 deletions gateway/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,20 @@ function resolveCodexCommand() {
return { command: 'codex', prefixArgs: [], shell: process.platform === 'win32' };
}

function resolveOpenCodeCommand() {
if (process.env.OPENCODE_BIN) return commandFromPath(process.env.OPENCODE_BIN);
if (process.platform === 'win32') {
const exe = path.join(
process.env.APPDATA || '',
'npm',
'node_modules',
'opencode-ai',
'bin',
'opencode.exe',
);
if (fs.existsSync(exe)) {
return { command: exe, prefixArgs: [], shell: false };
}
}
return {
command: 'opencode',
function resolveOpenCodeCommand() {
if (process.env.OPENCODE_BIN) return commandFromPath(process.env.OPENCODE_BIN);
if (process.platform === 'win32') {
const exe = findGlobalNpmPackageBin('opencode-ai', ['bin', 'opencode.exe']);
if (fs.existsSync(exe)) {
return { command: exe, prefixArgs: [], shell: false };
}
const shim = path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd');
if (fs.existsSync(shim)) {
return { command: shim, prefixArgs: [], shell: true };
}
}
return {
command: 'opencode',
prefixArgs: [],
shell: process.platform === 'win32',
};
Expand Down Expand Up @@ -82,12 +79,45 @@ function commandFromPath(command) {
shell: false,
};
}
return { command, prefixArgs: [], shell: false };
}

function commandExists(spec) {
if (spec.command === process.execPath) {
return (spec.prefixArgs || []).every((arg) => fs.existsSync(arg));
return { command, prefixArgs: [], shell: false };
}

function findGlobalNpmPackageBin(packageName, relativeParts) {
const nodeModules = path.join(process.env.APPDATA || '', 'npm', 'node_modules');
const exact = path.join(nodeModules, packageName, ...relativeParts);
if (fs.existsSync(exact)) return exact;

let directories = [];
try {
directories = fs
.readdirSync(nodeModules, { withFileTypes: true })
.filter((entry) => (
entry.isDirectory() &&
entry.name.startsWith(`.${packageName}-`)
))
.map((entry) => path.join(nodeModules, entry.name));
} catch {
return exact;
}

directories.sort((a, b) => {
try {
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
} catch {
return 0;
}
});

for (const directory of directories) {
const candidate = path.join(directory, ...relativeParts);
if (fs.existsSync(candidate)) return candidate;
}
return exact;
}

function commandExists(spec) {
if (spec.command === process.execPath) {
return (spec.prefixArgs || []).every((arg) => fs.existsSync(arg));
}
if (path.isAbsolute(spec.command)) return fs.existsSync(spec.command);
return findOnPath(spec.command) !== null;
Expand Down
88 changes: 72 additions & 16 deletions gateway/src/credential_sources.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* Supported providers:
* - anthropic (Claude)
* - openai (Codex)
* - opencode (OpenCode)
*
* Sources:
* - official: per-provider config files (~/.claude/settings.json,
Expand All @@ -31,7 +32,9 @@ const CC_SWITCH_DB_PATH = path.join(os.homedir(), '.cc-switch', 'cc-switch.db');

const APP_TYPE_TO_PROVIDER = {
claude: 'anthropic',
'claude-desktop': 'anthropic',
codex: 'openai',
opencode: 'opencode',
};

const CACHE_TTL_MS = 30_000;
Expand Down Expand Up @@ -85,7 +88,7 @@ function configurePaths(options) {
* @typedef {Object} CredentialEntry
* @property {string} id Stable identifier within the source.
* @property {'official'|'cc-switch'} source
* @property {'anthropic'|'openai'} provider Gateway provider slot for this credential.
* @property {'anthropic'|'openai'|'opencode'} provider Gateway provider slot for this credential.
* @property {string} label Human-readable name.
* @property {boolean} hasToken True iff a token was found.
* @property {string|null} authToken Raw token. **Caller must mask before HTTP.**
Expand Down Expand Up @@ -234,15 +237,20 @@ async function _readCcSwitchCredentials() {
const extracted = _extractCcSwitchCred(row);
if (!extracted || !extracted.authToken) continue;
entries.push({
id: String(row.id),
id: _ccSwitchEntryId(row),
source: 'cc-switch',
provider,
label: row.name || `${row.app_type} #${row.id}`,
hasToken: true,
authToken: extracted.authToken,
baseUrl: extracted.baseUrl,
isCurrent: Boolean(row.is_current),
raw: { providerId: row.id, appType: row.app_type },
raw: {
providerId: row.id,
appType: row.app_type,
...(row.provider_type ? { providerType: row.provider_type } : {}),
...(extracted.models ? { models: extracted.models } : {}),
},
});
}
return entries;
Expand All @@ -257,36 +265,79 @@ async function _readCcSwitchCredentials() {
}
}

function _ccSwitchEntryId(row) {
return `${row.app_type}:${row.id}`;
}

function _extractCcSwitchCred(row) {
if (row.app_type === 'claude') {
if (row.app_type === 'claude' || row.app_type === 'claude-desktop') {
const cfg = _tryParseJson(row.settings_config);
const env = (cfg && cfg.env) || {};
const authToken = env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || null;
if (!authToken) return null;
return { authToken, baseUrl: env.ANTHROPIC_BASE_URL || null };
}
if (row.app_type === 'codex') {
// CC-Switch stores Codex creds in `auth_config` (JSON) when available;
// older schemas might use `settings_config`.
const settings = _tryParseJson(row.settings_config);
// CC-Switch has used both a dedicated `auth_config` column and a nested
// `settings_config.auth` object across versions.
const auth =
_tryParseJson(row.auth_config) || _tryParseJson(row.settings_config);
_tryParseJson(row.auth_config) ||
(settings && typeof settings.auth === 'object' ? settings.auth : null) ||
settings;
const authToken =
(auth && (auth.OPENAI_API_KEY || auth.openai_api_key || auth.apiKey)) ||
(auth &&
(auth.OPENAI_API_KEY ||
auth.openai_api_key ||
auth.apiKey ||
auth.api_key)) ||
null;
let baseUrl =
(auth && (auth.OPENAI_BASE_URL || auth.openai_base_url)) || null;
// Codex `settings_config` is typically TOML; do a cheap regex grab if we
// didn't find a base URL in the auth blob.
if (!baseUrl && typeof row.settings_config === 'string') {
const match = row.settings_config.match(/base_url\s*=\s*"([^"]+)"/);
if (match) baseUrl = match[1];
}
(auth &&
(auth.OPENAI_BASE_URL ||
auth.openai_base_url ||
auth.baseUrl ||
auth.base_url)) ||
null;
const configText =
(settings && typeof settings.config === 'string' && settings.config) ||
(typeof row.settings_config === 'string' ? row.settings_config : '');
if (!baseUrl) baseUrl = _extractTomlString(configText, 'base_url');
if (!authToken) return null;
return { authToken, baseUrl };
}
if (row.app_type === 'opencode') {
const cfg = _tryParseJson(row.settings_config);
const options = cfg && typeof cfg.options === 'object' ? cfg.options : {};
const authToken =
options.apiKey ||
options.api_key ||
options.OPENAI_API_KEY ||
options.openai_api_key ||
null;
const baseUrl =
options.baseURL ||
options.baseUrl ||
options.base_url ||
options.OPENAI_BASE_URL ||
options.openai_base_url ||
null;
if (!authToken) return null;
const models = cfg && typeof cfg.models === 'object'
? Object.keys(cfg.models)
: undefined;
return { authToken, baseUrl, models };
}
return null;
}

function _extractTomlString(raw, key) {
if (typeof raw !== 'string' || !raw) return null;
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = raw.match(new RegExp(`^\\s*${escaped}\\s*=\\s*"([^"]+)"`, 'm'));
return match ? match[1] : null;
}

function _tryParseJson(raw) {
if (typeof raw !== 'string' || !raw) return null;
try {
Expand Down Expand Up @@ -315,7 +366,12 @@ async function loadCredential({ source, sourceId } = {}) {
if (source === 'cc-switch') {
const entries = await listCcSwitchCredentials();
if (sourceId != null && sourceId !== '') {
return entries.find((e) => e.id === String(sourceId)) || null;
const id = String(sourceId);
return entries.find((e) => (
e.id === id ||
String(e.raw?.providerId) === id ||
`${e.raw?.appType}:${e.raw?.providerId}` === id
)) || null;
}
return entries.find((e) => e.isCurrent) || entries[0] || null;
}
Expand Down
50 changes: 50 additions & 0 deletions gateway/test/cli.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';

const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');

const {
commandExists,
resolveOpenCodeCommand,
} = require('../src/cli');

test(
'resolveOpenCodeCommand finds npm temp package installs on Windows',
{ skip: process.platform !== 'win32' },
async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rma-cli-'));
const oldAppData = process.env.APPDATA;
const oldBin = process.env.OPENCODE_BIN;
t.after(async () => {
if (oldAppData === undefined) delete process.env.APPDATA;
else process.env.APPDATA = oldAppData;
if (oldBin === undefined) delete process.env.OPENCODE_BIN;
else process.env.OPENCODE_BIN = oldBin;
await fs.rm(root, { recursive: true, force: true });
});

delete process.env.OPENCODE_BIN;
process.env.APPDATA = root;

const exe = path.join(
root,
'npm',
'node_modules',
'.opencode-ai-AbCdEf',
'bin',
'opencode.exe',
);
await fs.mkdir(path.dirname(exe), { recursive: true });
await fs.writeFile(exe, '');

const command = resolveOpenCodeCommand();

assert.equal(command.command, exe);
assert.deepEqual(command.prefixArgs, []);
assert.equal(command.shell, false);
assert.equal(commandExists(command), true);
},
);
Loading
Loading