@commonlyai/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/package.json +55 -0
- package/src/commands/agent.js +1264 -0
- package/src/commands/dev.js +653 -0
- package/src/commands/login.js +105 -0
- package/src/commands/pod.js +123 -0
- package/src/index.js +73 -0
- package/src/lib/adapters/claude.js +296 -0
- package/src/lib/adapters/codex.js +251 -0
- package/src/lib/adapters/index.js +22 -0
- package/src/lib/adapters/stub.js +24 -0
- package/src/lib/api.js +62 -0
- package/src/lib/config.js +138 -0
- package/src/lib/environment.js +326 -0
- package/src/lib/memory-bridge.js +54 -0
- package/src/lib/memory-import.js +156 -0
- package/src/lib/poller.js +89 -0
- package/src/lib/sandbox/bwrap.js +127 -0
- package/src/lib/session-store.js +56 -0
- package/src/lib/skills-import.js +112 -0
- package/src/lib/webhook-server.js +88 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex adapter — wraps the local `codex` CLI as a Commonly agent.
|
|
3
|
+
*
|
|
4
|
+
* Contract: ADR-005 §Adapter pattern.
|
|
5
|
+
*
|
|
6
|
+
* Tested against codex-cli 0.125.0. The argv shape diverges from the
|
|
7
|
+
* ADR-005 §Adapters-shipped-in-v1 table (which was written against an
|
|
8
|
+
* earlier codex-acp variant): modern codex uses `codex exec resume <id>`
|
|
9
|
+
* as a subcommand to continue a session, NOT a `--session <id>` flag.
|
|
10
|
+
* If a future codex bumps the surface again, this adapter is the single
|
|
11
|
+
* file to update.
|
|
12
|
+
*
|
|
13
|
+
* Output shape:
|
|
14
|
+
* - stdout = JSONL events: `{"type":"thread.started","thread_id":"<uuid>"}`,
|
|
15
|
+
* `{"type":"turn.started"}`, `{"type":"turn.failed","error":{...}}`, etc.
|
|
16
|
+
* - stderr = Rust tracing logs (timestamped), unrelated to model output.
|
|
17
|
+
*
|
|
18
|
+
* We capture session id from the `thread.started` event and read the agent's
|
|
19
|
+
* final reply from the file written via `-o <FILE>` (codex's
|
|
20
|
+
* `--output-last-message` short alias) — cleaner than parsing every
|
|
21
|
+
* event-type variant the model can emit.
|
|
22
|
+
*
|
|
23
|
+
* Memory preamble: if ctx.memoryLongTerm is non-empty, the adapter prepends
|
|
24
|
+
* it to the prompt as a system-context preamble (§Memory bridge), matching
|
|
25
|
+
* the claude adapter's shape so the run loop's per-event memory plumbing
|
|
26
|
+
* works identically across drivers.
|
|
27
|
+
*
|
|
28
|
+
* Purity (§Load-bearing invariants #1): input = argv + env + prompt;
|
|
29
|
+
* output = text + session id. No direct network, no direct CAP calls.
|
|
30
|
+
*
|
|
31
|
+
* Test seam: `ctx._spawnImpl` is the sanctioned way for any adapter in this
|
|
32
|
+
* codebase to swap `child_process.spawn` out for a mock. Same convention as
|
|
33
|
+
* claude.js so future adapters stay uniform.
|
|
34
|
+
*
|
|
35
|
+
* Timeout caveat: SIGTERM at `timeoutMs`, then wait for `close`. If codex
|
|
36
|
+
* ignores SIGTERM the promise never resolves — same caveat as claude.js;
|
|
37
|
+
* SIGKILL escalation is a post-v1 concern (ADR-005 §Spawning semantics).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
41
|
+
import { mkdtemp, readFile, rm } from 'fs/promises';
|
|
42
|
+
import { tmpdir } from 'os';
|
|
43
|
+
import { join } from 'path';
|
|
44
|
+
|
|
45
|
+
// Default timeout for a single codex spawn (exec mode).
|
|
46
|
+
//
|
|
47
|
+
// 2026-05-20 smoke surface: Cody's cycle-0 research task ran 15+ web
|
|
48
|
+
// searches across openai/codex release notes (a legitimate research
|
|
49
|
+
// workload) and was SIGTERM'd at exactly 300_000ms — the adapter killed
|
|
50
|
+
// her before she could synthesize the final reply. 5 minutes is too
|
|
51
|
+
// tight for codex `exec` mode under real tool-use; the bigger model
|
|
52
|
+
// (gpt-5.4) + web.run + reasoning is just slower than a chat turn.
|
|
53
|
+
// Bumping to 15 minutes gives multi-step research / repo investigation
|
|
54
|
+
// room to finish without disabling the cap (pathological hangs still
|
|
55
|
+
// SIGTERM).
|
|
56
|
+
//
|
|
57
|
+
// Operators can override via the COMMONLY_AGENT_RUN_TIMEOUT_MS env var
|
|
58
|
+
// without rebuilding (caller-supplied `ctx.timeoutMs` still wins).
|
|
59
|
+
// Invalid / non-positive values fall through to the default rather
|
|
60
|
+
// than disabling the timeout entirely.
|
|
61
|
+
const DEFAULT_TIMEOUT_MS = (() => {
|
|
62
|
+
const fallback = 15 * 60 * 1000;
|
|
63
|
+
const raw = process.env.COMMONLY_AGENT_RUN_TIMEOUT_MS;
|
|
64
|
+
if (!raw) return fallback;
|
|
65
|
+
const parsed = Number(raw);
|
|
66
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
67
|
+
})();
|
|
68
|
+
|
|
69
|
+
const buildPrompt = (prompt, memoryLongTerm) => {
|
|
70
|
+
if (!memoryLongTerm) return prompt;
|
|
71
|
+
return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Build the argv after the `codex` binary. Resume vs new turn is a
|
|
75
|
+
// subcommand-level distinction in modern codex, not an option flag — keep
|
|
76
|
+
// that detail isolated here so the spawn path stays linear.
|
|
77
|
+
const buildArgs = ({ sessionId, prompt, outputFile }) => {
|
|
78
|
+
// `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
|
|
79
|
+
// bubblewrap (bwrap) sandbox + approval prompts. bwrap needs CAP_SYS_ADMIN
|
|
80
|
+
// or unprivileged user-namespaces — neither available to standard k8s
|
|
81
|
+
// containers without elevated securityContext. Without this flag, every
|
|
82
|
+
// shell tool call (git, ls, pwd, ...) fails with "bwrap: Failed to make /
|
|
83
|
+
// slave: Permission denied" inside cloud-codex pods (verified 2026-05-15).
|
|
84
|
+
// The pod is the security perimeter — agent identity is isolated, workspace
|
|
85
|
+
// is PVC-scoped, no host mounts. bwrap inside the pod is redundant.
|
|
86
|
+
// On a laptop wrapper run (sam-local-codex etc.) the same flag is fine: the
|
|
87
|
+
// operator's machine is already the security boundary they signed up for
|
|
88
|
+
// when running `commonly agent run`.
|
|
89
|
+
const common = [
|
|
90
|
+
'--json',
|
|
91
|
+
'--skip-git-repo-check',
|
|
92
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
93
|
+
'-o',
|
|
94
|
+
outputFile,
|
|
95
|
+
];
|
|
96
|
+
if (sessionId) {
|
|
97
|
+
// Place <sessionId> immediately after the `exec resume` subcommand so a
|
|
98
|
+
// future codex parser change can't accidentally consume it as the value
|
|
99
|
+
// of a preceding flag (e.g. -o). Codex's CLI signature is documented as
|
|
100
|
+
// `codex exec resume [OPTIONS] [SESSION_ID] [PROMPT]`, and this ordering
|
|
101
|
+
// matches that intent unambiguously regardless of clap version.
|
|
102
|
+
return ['exec', 'resume', sessionId, ...common, prompt];
|
|
103
|
+
}
|
|
104
|
+
return ['exec', ...common, prompt];
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Stream-parse JSONL stdout. Codex emits one event per line; partial lines
|
|
108
|
+
// across chunk boundaries are buffered and flushed on the next newline.
|
|
109
|
+
const makeEventParser = () => {
|
|
110
|
+
let buffer = '';
|
|
111
|
+
let threadId = null;
|
|
112
|
+
let turnFailedMessage = null;
|
|
113
|
+
|
|
114
|
+
const consume = (chunk) => {
|
|
115
|
+
buffer += chunk.toString();
|
|
116
|
+
let nl;
|
|
117
|
+
// eslint-disable-next-line no-cond-assign
|
|
118
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
119
|
+
const line = buffer.slice(0, nl).trim();
|
|
120
|
+
buffer = buffer.slice(nl + 1);
|
|
121
|
+
if (!line) continue;
|
|
122
|
+
try {
|
|
123
|
+
const evt = JSON.parse(line);
|
|
124
|
+
if (evt.type === 'thread.started' && evt.thread_id && !threadId) {
|
|
125
|
+
threadId = evt.thread_id;
|
|
126
|
+
} else if (evt.type === 'turn.failed') {
|
|
127
|
+
// Capture the most recent — codex may emit multiple before exit.
|
|
128
|
+
turnFailedMessage = evt.error?.message || 'codex turn failed';
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// Non-JSON lines on stdout are unexpected but not fatal — skip.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
consume,
|
|
138
|
+
get threadId() { return threadId; },
|
|
139
|
+
get turnFailedMessage() { return turnFailedMessage; },
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const runCodex = ({ args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
144
|
+
// stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
|
|
145
|
+
// defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
|
|
146
|
+
// `Reading additional input from stdin...` because it sees an open pipe
|
|
147
|
+
// and waits for input that never arrives. Interactive runs are fine because
|
|
148
|
+
// codex detects a TTY and uses the argv prompt directly. Setting stdin to
|
|
149
|
+
// `'ignore'` gives codex /dev/null → immediate EOF → it falls back to the
|
|
150
|
+
// argv prompt as intended. Surfaced live during ADR-005 Phase 2 smoke.
|
|
151
|
+
const proc = spawnImpl('codex', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
152
|
+
let stderr = '';
|
|
153
|
+
let timedOut = false;
|
|
154
|
+
const events = makeEventParser();
|
|
155
|
+
|
|
156
|
+
const timer = setTimeout(() => {
|
|
157
|
+
timedOut = true;
|
|
158
|
+
proc.kill('SIGTERM');
|
|
159
|
+
}, timeoutMs);
|
|
160
|
+
|
|
161
|
+
proc.stdout?.on('data', (chunk) => events.consume(chunk));
|
|
162
|
+
proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
163
|
+
proc.on('error', (err) => {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
reject(err);
|
|
166
|
+
});
|
|
167
|
+
proc.on('close', (code) => {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
if (timedOut) return reject(new Error(`codex timed out after ${timeoutMs}ms`));
|
|
170
|
+
if (events.turnFailedMessage) {
|
|
171
|
+
// Surface the model-side failure message verbatim — the run loop posts
|
|
172
|
+
// it as the agent's reply so the user sees what went wrong rather than
|
|
173
|
+
// a generic "non-zero exit" error.
|
|
174
|
+
return reject(new Error(`codex turn failed: ${events.turnFailedMessage}`));
|
|
175
|
+
}
|
|
176
|
+
if (code !== 0) {
|
|
177
|
+
return reject(new Error(`codex exited with code ${code}: ${stderr.trim().slice(0, 500)}`));
|
|
178
|
+
}
|
|
179
|
+
resolve({ threadId: events.threadId });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
export default {
|
|
184
|
+
name: 'codex',
|
|
185
|
+
// See claude.js for the rationale — identity-bearing runtime tag persisted
|
|
186
|
+
// to AgentInstallation.config.runtime (paired with host:'byo') so a CLI-
|
|
187
|
+
// attached Codex agent and a cloud-hosted Codex agent share the same
|
|
188
|
+
// `runtimeType` and differ only on `host`.
|
|
189
|
+
runtimeType: 'codex',
|
|
190
|
+
|
|
191
|
+
async detect() {
|
|
192
|
+
try {
|
|
193
|
+
const res = spawnSync('codex', ['--version'], { encoding: 'utf8' });
|
|
194
|
+
if (res.error || res.status !== 0) return null;
|
|
195
|
+
// `codex --version` prints e.g. "codex-cli 0.125.0" — last token is
|
|
196
|
+
// the version. Defensive against future format tweaks: if no
|
|
197
|
+
// dotted-numeric token is found, fall back to the raw stdout.
|
|
198
|
+
const stdout = (res.stdout || '').trim();
|
|
199
|
+
const versionMatch = stdout.match(/(\d+\.\d+(?:\.\d+)?)/);
|
|
200
|
+
const version = versionMatch ? versionMatch[1] : (stdout || 'unknown');
|
|
201
|
+
const where = spawnSync('which', ['codex'], { encoding: 'utf8' });
|
|
202
|
+
const path = where.status === 0 ? (where.stdout || '').trim() || 'codex' : 'codex';
|
|
203
|
+
return { path, version };
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
async spawn(prompt, ctx = {}) {
|
|
210
|
+
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
|
|
211
|
+
|
|
212
|
+
// Per-spawn temp dir for --output-last-message. Cleaned up in `finally`
|
|
213
|
+
// so a crash in the middle of the spawn doesn't leak files in $TMPDIR.
|
|
214
|
+
const dir = await mkdtemp(join(tmpdir(), 'commonly-codex-'));
|
|
215
|
+
const outputFile = join(dir, 'last-message.txt');
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const args = buildArgs({
|
|
219
|
+
sessionId: ctx.sessionId || null,
|
|
220
|
+
prompt: fullPrompt,
|
|
221
|
+
outputFile,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const { threadId } = await runCodex({
|
|
225
|
+
args,
|
|
226
|
+
cwd: ctx.cwd,
|
|
227
|
+
env: ctx.env,
|
|
228
|
+
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
229
|
+
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
let text = '';
|
|
233
|
+
try {
|
|
234
|
+
text = (await readFile(outputFile, 'utf8')).trim();
|
|
235
|
+
} catch {
|
|
236
|
+
// codex didn't write the file — empty turn or stream parsing edge.
|
|
237
|
+
// Caller (run loop) treats empty text as a failed spawn and re-delivers.
|
|
238
|
+
text = '';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// newSessionId precedence: thread_id from this turn (always emitted on
|
|
242
|
+
// a new session, often re-emitted on resume) > the persisted id we
|
|
243
|
+
// came in with > null. The wrapper persists whichever non-null value
|
|
244
|
+
// we return so subsequent turns hit `codex exec resume <id>`.
|
|
245
|
+
const newSessionId = threadId || ctx.sessionId || null;
|
|
246
|
+
return { text, newSessionId };
|
|
247
|
+
} finally {
|
|
248
|
+
try { await rm(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter registry — ADR-005 §Adapter pattern.
|
|
3
|
+
*
|
|
4
|
+
* `attach <adapter>` and `run <name>` both resolve an adapter by its string
|
|
5
|
+
* name through this single registry. Adding a new CLI (claude, codex, cursor,
|
|
6
|
+
* gemini, …) is a one-file PR that exports a default adapter and adds an
|
|
7
|
+
* entry below.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import stub from './stub.js';
|
|
11
|
+
import claude from './claude.js';
|
|
12
|
+
import codex from './codex.js';
|
|
13
|
+
|
|
14
|
+
const ADAPTERS = {
|
|
15
|
+
[stub.name]: stub,
|
|
16
|
+
[claude.name]: claude,
|
|
17
|
+
[codex.name]: codex,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const listAdapterNames = () => Object.keys(ADAPTERS);
|
|
21
|
+
|
|
22
|
+
export const getAdapter = (name) => ADAPTERS[name] || null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub adapter — the no-op reference implementation of the ADR-005 adapter
|
|
3
|
+
* contract. Used by `commonly agent attach stub` and the Phase-1a test
|
|
4
|
+
* harness so the run loop can be reviewed end-to-end without a real CLI
|
|
5
|
+
* on PATH.
|
|
6
|
+
*
|
|
7
|
+
* Contract (ADR-005 §Adapter pattern):
|
|
8
|
+
* detect(): Promise<{ path, version } | null>
|
|
9
|
+
* spawn(prompt, ctx): Promise<{ text, newSessionId?, memorySummary? }>
|
|
10
|
+
*
|
|
11
|
+
* The adapter MUST be pure: input (argv + env + prompt) → output (text + optional
|
|
12
|
+
* next session-id + optional memory summary). No direct network, no direct API
|
|
13
|
+
* calls, no hidden state. The run loop handles all Commonly-facing I/O.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
name: 'stub',
|
|
18
|
+
async detect() {
|
|
19
|
+
return { path: '(builtin)', version: '0.0.0' };
|
|
20
|
+
},
|
|
21
|
+
async spawn(prompt, _ctx) {
|
|
22
|
+
return { text: `(stub) received: ${String(prompt ?? '').slice(0, 200)}` };
|
|
23
|
+
},
|
|
24
|
+
};
|
package/src/lib/api.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CAP HTTP client — thin wrapper over fetch.
|
|
3
|
+
*
|
|
4
|
+
* Every method reads the active instance URL and token from config
|
|
5
|
+
* unless overridden. This is the only place that makes HTTP calls.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolveInstanceUrl, getToken } from './config.js';
|
|
9
|
+
|
|
10
|
+
const headers = (token, extra = {}) => ({
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
13
|
+
...extra,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const handleResponse = async (res) => {
|
|
17
|
+
const text = await res.text();
|
|
18
|
+
let body;
|
|
19
|
+
try { body = JSON.parse(text); } catch { body = { message: text }; }
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
const msg = body?.error || body?.message || `HTTP ${res.status}`;
|
|
22
|
+
const err = new Error(msg);
|
|
23
|
+
err.status = res.status;
|
|
24
|
+
err.body = body;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
return body;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const createClient = ({ instance = null, token = null } = {}) => {
|
|
31
|
+
const baseUrl = resolveInstanceUrl(instance);
|
|
32
|
+
const authToken = token || getToken(instance);
|
|
33
|
+
|
|
34
|
+
const get = (path, params = {}) => {
|
|
35
|
+
const url = new URL(`${baseUrl}${path}`);
|
|
36
|
+
Object.entries(params).forEach(([k, v]) => v != null && url.searchParams.set(k, v));
|
|
37
|
+
return fetch(url.toString(), { headers: headers(authToken) }).then(handleResponse);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const post = (path, body = {}) => fetch(`${baseUrl}${path}`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: headers(authToken),
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
}).then(handleResponse);
|
|
45
|
+
|
|
46
|
+
const del = (path) => fetch(`${baseUrl}${path}`, {
|
|
47
|
+
method: 'DELETE',
|
|
48
|
+
headers: headers(authToken),
|
|
49
|
+
}).then(handleResponse);
|
|
50
|
+
|
|
51
|
+
return { get, post, del, baseUrl };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Convenience: login doesn't need a token
|
|
55
|
+
export const login = async (instanceUrl, email, password) => {
|
|
56
|
+
const res = await fetch(`${instanceUrl}/api/auth/login`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ email, password }),
|
|
60
|
+
});
|
|
61
|
+
return handleResponse(res);
|
|
62
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config management — ~/.commonly/config.json
|
|
3
|
+
*
|
|
4
|
+
* Stores auth tokens and instance URLs across sessions.
|
|
5
|
+
* Multiple instances supported (default, local, custom).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
|
|
12
|
+
const CONFIG_DIR = join(homedir(), '.commonly');
|
|
13
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
14
|
+
|
|
15
|
+
const DEFAULT_INSTANCE_URL = 'https://api.commonly.me';
|
|
16
|
+
const LOCAL_INSTANCE_URL = 'http://localhost:5000';
|
|
17
|
+
|
|
18
|
+
const read = () => {
|
|
19
|
+
if (!existsSync(CONFIG_FILE)) return { instances: {}, active: 'default' };
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return { instances: {}, active: 'default' };
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const write = (config) => {
|
|
28
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
29
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const getConfig = () => read();
|
|
33
|
+
|
|
34
|
+
const normalizeUrl = (value) => (value || '').replace(/\/$/, '').toLowerCase();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve an instance identifier to the saved instance record.
|
|
38
|
+
*
|
|
39
|
+
* Accepts either form:
|
|
40
|
+
* - A saved key: 'dev', 'default', 'local'
|
|
41
|
+
* - A URL: 'https://api.commonly.me' (any case; trailing / ok)
|
|
42
|
+
* - null: falls back to config.active
|
|
43
|
+
*
|
|
44
|
+
* Historically `resolveInstanceUrl` treated the arg as a URL and `getToken`
|
|
45
|
+
* treated it as a key — so `--instance dev` and `--instance <url>` each
|
|
46
|
+
* worked for exactly one of URL-resolution or token-lookup and broke the
|
|
47
|
+
* other. Funneling both through this helper closes that asymmetry.
|
|
48
|
+
*
|
|
49
|
+
* URL-shaped inputs (http[s]://) go through URL resolution first so a
|
|
50
|
+
* hypothetical key named `"https-backup"` can't collide with a real URL.
|
|
51
|
+
* For an unknown URL (no saved match) we return the URL with a null token,
|
|
52
|
+
* which is needed for login bootstrap (there is no saved instance yet) and
|
|
53
|
+
* for unauthenticated probe calls.
|
|
54
|
+
*/
|
|
55
|
+
export const resolveInstance = (identifier = null) => {
|
|
56
|
+
const config = read();
|
|
57
|
+
|
|
58
|
+
if (!identifier) {
|
|
59
|
+
const key = config.active || 'default';
|
|
60
|
+
const inst = config.instances[key];
|
|
61
|
+
return inst ? { key, ...inst } : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// URL-shaped inputs: resolve by URL first. Case-insensitive match so that
|
|
65
|
+
// `HTTPS://API-DEV.commonly.me/` finds a record saved as the lowercased
|
|
66
|
+
// `https://api.commonly.me`.
|
|
67
|
+
const looksLikeUrl = /^https?:\/\//i.test(identifier);
|
|
68
|
+
if (looksLikeUrl) {
|
|
69
|
+
const normalized = normalizeUrl(identifier);
|
|
70
|
+
const urlEntry = Object.entries(config.instances)
|
|
71
|
+
.find(([, v]) => normalizeUrl(v.url) === normalized);
|
|
72
|
+
if (urlEntry) {
|
|
73
|
+
const [key, value] = urlEntry;
|
|
74
|
+
return { key, ...value };
|
|
75
|
+
}
|
|
76
|
+
// Unknown URL — still usable for bootstrapping. Preserve the caller's
|
|
77
|
+
// original case in the returned URL (some servers are case-sensitive on
|
|
78
|
+
// paths; we only lowercase for match comparison above).
|
|
79
|
+
return { key: null, url: identifier.replace(/\/$/, ''), token: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Key-shaped inputs: exact lookup only.
|
|
83
|
+
if (config.instances[identifier]) {
|
|
84
|
+
return { key: identifier, ...config.instances[identifier] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Unknown key — caller falls back to defaults.
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Alias kept for existing imports (login.js). Functionally identical to
|
|
92
|
+
// resolveInstance; callers using this name pre-date the refactor.
|
|
93
|
+
export const getActiveInstance = (instanceOverride = null) => resolveInstance(instanceOverride);
|
|
94
|
+
|
|
95
|
+
export const resolveInstanceUrl = (instanceArg = null) => {
|
|
96
|
+
const inst = resolveInstance(instanceArg);
|
|
97
|
+
if (inst?.url) return inst.url.replace(/\/$/, '');
|
|
98
|
+
return DEFAULT_INSTANCE_URL;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const saveInstance = ({ key = 'default', url, token, userId, username }) => {
|
|
102
|
+
const config = read();
|
|
103
|
+
config.instances[key] = {
|
|
104
|
+
url: url.replace(/\/$/, ''),
|
|
105
|
+
token,
|
|
106
|
+
userId,
|
|
107
|
+
username,
|
|
108
|
+
savedAt: new Date().toISOString(),
|
|
109
|
+
};
|
|
110
|
+
config.active = key;
|
|
111
|
+
write(config);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const setActive = (key) => {
|
|
115
|
+
const config = read();
|
|
116
|
+
if (!config.instances[key]) throw new Error(`Instance '${key}' not found in config`);
|
|
117
|
+
config.active = key;
|
|
118
|
+
write(config);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const getToken = (instanceOverride = null) => {
|
|
122
|
+
// Env var takes precedence (CI, scripts)
|
|
123
|
+
if (process.env.COMMONLY_TOKEN) return process.env.COMMONLY_TOKEN;
|
|
124
|
+
const inst = resolveInstance(instanceOverride);
|
|
125
|
+
return inst?.token || null;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const listInstances = () => {
|
|
129
|
+
const config = read();
|
|
130
|
+
return Object.entries(config.instances).map(([key, val]) => ({
|
|
131
|
+
key,
|
|
132
|
+
...val,
|
|
133
|
+
active: key === config.active,
|
|
134
|
+
}));
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const LOCAL_URL = LOCAL_INSTANCE_URL;
|
|
138
|
+
export const DEFAULT_URL = DEFAULT_INSTANCE_URL;
|