@oppira/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.
@@ -0,0 +1,93 @@
1
+ // scrape: on-demand intelligent scrape (local engine) + raw per-platform
2
+ // endpoints (remote). The intelligent path is the one that mirrors S3, recomputes
3
+ // summaries, and fires battlecard/profile triggers — the raw path hits a single
4
+ // Apify actor and bypasses that pipeline (and is currently unauthenticated).
5
+ import { apiRequest } from '../http.js';
6
+ import { getCredentials } from '../config.js';
7
+ import { withDb, findUserOrThrow, findCompetitorsForUser, backendImport } from '../local.js';
8
+ import { out, info } from '../output.js';
9
+
10
+ // CLI short form → canonical platform key used by scrapeCompetitorIntelligent.
11
+ const PLATFORM_ALIASES = {
12
+ fb: 'facebook', facebook: 'facebook',
13
+ ig: 'instagram', instagram: 'instagram',
14
+ x: 'x', twitter: 'x',
15
+ li: 'linkedin', linkedin: 'linkedin',
16
+ };
17
+
18
+ const RAW_ENDPOINTS = {
19
+ facebook: '/scrape-facebook',
20
+ 'facebook-posts': '/scrape-facebook-posts',
21
+ 'facebook-ads': '/scrape-facebook-ads',
22
+ 'google-ads': '/scrape-google-ads',
23
+ instagram: '/scrape-instagram',
24
+ twitter: '/scrape-twitter',
25
+ linkedin: '/scrape-linkedin',
26
+ trustpilot: '/scrape-trustpilot',
27
+ };
28
+
29
+ function opsEmail(ctx) {
30
+ const email = ctx.flag('email') || getCredentials(ctx.profileName).email;
31
+ if (!email) throw new Error('Provide --email (or log in) to select the target user.');
32
+ return email;
33
+ }
34
+
35
+ export default {
36
+ description: 'Scraping — on-demand intelligent scrape (ops) + raw per-platform.',
37
+ commands: {
38
+ run: {
39
+ description: 'Intelligent on-demand scrape of a competitor now (ops / local mode).',
40
+ usage: 'scrape run <competitor> [--platforms fb,ig,x,li] [--email you@co.com]',
41
+ flags: [
42
+ { name: 'platforms', description: 'Comma list to limit (fb,ig,x,li); default all social' },
43
+ { name: 'email', description: 'Target user (defaults to logged-in profile)' },
44
+ ],
45
+ async handler(ctx) {
46
+ const email = opsEmail(ctx);
47
+ const nameQuery = ctx.arg(0);
48
+ if (!nameQuery) throw new Error('Usage: oppira scrape run <competitor> [--platforms fb,ig]');
49
+
50
+ let allowedPlatforms = null;
51
+ if (ctx.flag('platforms')) {
52
+ allowedPlatforms = String(ctx.flag('platforms')).split(',')
53
+ .map((p) => PLATFORM_ALIASES[p.trim().toLowerCase()])
54
+ .filter(Boolean);
55
+ if (allowedPlatforms.length === 0) throw new Error('No valid platforms in --platforms (use fb,ig,x,li).');
56
+ }
57
+
58
+ const result = await withDb(async () => {
59
+ const user = await findUserOrThrow(email);
60
+ const competitors = await findCompetitorsForUser(user, nameQuery);
61
+ if (competitors.length === 0) throw new Error(`No competitor matches "${nameQuery}" for ${email}.`);
62
+ if (competitors.length > 1) throw new Error(`Ambiguous: ${competitors.map((c) => c.name).join(', ')}.`);
63
+ const { scrapeCompetitorIntelligent } = await backendImport('utils/scraperUtils.js');
64
+ info(`Scraping "${competitors[0].name}"${allowedPlatforms ? ` (${allowedPlatforms.join(', ')})` : ''}…`);
65
+ // requireScrapingFlag:false → on-demand semantics (scrape platforms not
66
+ // currently mid-scrape), rather than the cron's flag-driven path.
67
+ return scrapeCompetitorIntelligent(competitors[0], user, allowedPlatforms, { requireScrapingFlag: false });
68
+ });
69
+ if (ctx.json) { out({ success: true, email, competitor: nameQuery, result }, ctx); return; }
70
+ info(`✓ Scrape complete for "${nameQuery}".`);
71
+ },
72
+ },
73
+
74
+ raw: {
75
+ description: 'Call a single raw platform scraper (remote). Bypasses the intelligent pipeline.',
76
+ usage: 'scrape raw <platform> --url <url> (platforms: ' + Object.keys(RAW_ENDPOINTS).join(', ') + ')',
77
+ flags: [{ name: 'url', description: 'Profile / page URL to scrape' }],
78
+ async handler(ctx) {
79
+ const platform = ctx.arg(0);
80
+ const endpoint = RAW_ENDPOINTS[platform];
81
+ if (!endpoint) throw new Error(`Unknown platform "${platform}". One of: ${Object.keys(RAW_ENDPOINTS).join(', ')}`);
82
+ const url = ctx.flag('url');
83
+ if (!url) throw new Error('Usage: oppira scrape raw <platform> --url <url>');
84
+ const email = getCredentials(ctx.profileName).email;
85
+ const res = await apiRequest(ctx.profileName, 'POST', endpoint, {
86
+ body: { url, userEmails: email || '' },
87
+ });
88
+ if (ctx.json) { out(res, ctx); return; }
89
+ info(`✓ Raw ${platform} scrape done.`);
90
+ },
91
+ },
92
+ },
93
+ };
@@ -0,0 +1,185 @@
1
+ // studio: content-creation surface (Pro/admin). v1 covers the high-value
2
+ // surfaces — posts, calendars, comment inbox, plus generate/publish/draft-reply
3
+ // actions. Deeper template/image/policy/ads coverage is the plan's final phase.
4
+ import { apiRequest } from '../http.js';
5
+ import { out, table, info } from '../output.js';
6
+
7
+ export default {
8
+ description: 'Studio — content calendar, posts, comment inbox (Pro/admin).',
9
+ commands: {
10
+ posts: {
11
+ description: 'List Studio posts.',
12
+ async handler(ctx) {
13
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/posts');
14
+ const list = res?.data || res?.posts || res || [];
15
+ if (ctx.json) { out(list, ctx); return; }
16
+ const rows = (Array.isArray(list) ? list : []).map((p) => ({
17
+ id: String(p._id || p.id || '—'),
18
+ status: p.status || '—',
19
+ channel: p.channel || (p.channels && p.channels.join(',')) || '—',
20
+ scheduled: p.scheduledAt || p.publishAt || '—',
21
+ body: (p.body || p.caption || '').slice(0, 40),
22
+ }));
23
+ table(rows, [
24
+ { key: 'id', label: 'ID' }, { key: 'status', label: 'STATUS' },
25
+ { key: 'channel', label: 'CHANNEL' }, { key: 'scheduled', label: 'SCHEDULED' },
26
+ { key: 'body', label: 'BODY', max: 42 },
27
+ ], ctx);
28
+ },
29
+ },
30
+
31
+ publish: {
32
+ description: 'Publish a Studio post now.',
33
+ usage: 'studio publish <postId>',
34
+ async handler(ctx) {
35
+ const id = ctx.arg(0);
36
+ if (!id) throw new Error('Usage: oppira studio publish <postId>');
37
+ const res = await apiRequest(ctx.profileName, 'POST', `/studio/posts/${id}/publish`, { body: {} });
38
+ if (ctx.json) { out(res, ctx); return; }
39
+ info(`✓ Publish requested for post ${id}. ${res?.message || ''}`);
40
+ },
41
+ },
42
+
43
+ calendars: {
44
+ description: 'List content calendars.',
45
+ async handler(ctx) {
46
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/calendars');
47
+ out(res?.data || res, ctx);
48
+ },
49
+ },
50
+
51
+ 'calendar-generate': {
52
+ description: 'Generate a content calendar week.',
53
+ usage: 'studio calendar-generate [--week 2026-07-20] [--prompt "..."]',
54
+ flags: [
55
+ { name: 'week', description: 'Week start date (YYYY-MM-DD)' },
56
+ { name: 'prompt', description: 'Optional steering prompt' },
57
+ ],
58
+ async handler(ctx) {
59
+ const body = {};
60
+ if (ctx.flag('week')) body.weekStart = ctx.flag('week');
61
+ if (ctx.flag('prompt')) body.userPrompt = ctx.flag('prompt');
62
+ const res = await apiRequest(ctx.profileName, 'POST', '/studio/calendar/generate', { body });
63
+ if (ctx.json) { out(res, ctx); return; }
64
+ info(`✓ Calendar generation triggered. ${res?.message || ''}`);
65
+ },
66
+ },
67
+
68
+ comments: {
69
+ description: 'List Studio comment inbox (add --unread for the unread count).',
70
+ flags: [{ name: 'unread', boolean: true, description: 'Show unread count only' }],
71
+ async handler(ctx) {
72
+ if (ctx.bool('unread')) {
73
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/comments/unread-count');
74
+ out(res?.data || res, ctx);
75
+ return;
76
+ }
77
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/comments');
78
+ const list = res?.data || res?.comments || res || [];
79
+ if (ctx.json) { out(list, ctx); return; }
80
+ const rows = (Array.isArray(list) ? list : []).map((c) => ({
81
+ id: String(c._id || c.id || '—'),
82
+ channel: c.channel || '—',
83
+ author: c.author || c.authorName || '—',
84
+ text: (c.text || c.message || '').slice(0, 50),
85
+ read: c.read || c.isRead ? 'yes' : 'no',
86
+ }));
87
+ table(rows, [
88
+ { key: 'id', label: 'ID' }, { key: 'channel', label: 'CHANNEL' },
89
+ { key: 'author', label: 'AUTHOR' }, { key: 'text', label: 'TEXT', max: 52 },
90
+ { key: 'read', label: 'READ' },
91
+ ], ctx);
92
+ },
93
+ },
94
+
95
+ 'draft-reply': {
96
+ description: 'Draft an AI reply for a comment.',
97
+ usage: 'studio draft-reply <commentId>',
98
+ async handler(ctx) {
99
+ const id = ctx.arg(0);
100
+ if (!id) throw new Error('Usage: oppira studio draft-reply <commentId>');
101
+ const res = await apiRequest(ctx.profileName, 'POST', `/studio/comments/${id}/draft-reply`, { body: {} });
102
+ out(res?.data || res, ctx);
103
+ },
104
+ },
105
+
106
+ templates: {
107
+ description: 'List image templates.',
108
+ async handler(ctx) {
109
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/templates');
110
+ out(res?.data || res, ctx);
111
+ },
112
+ },
113
+
114
+ formats: {
115
+ description: 'List the format menu.',
116
+ async handler(ctx) {
117
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/formats');
118
+ out(res?.data || res, ctx);
119
+ },
120
+ },
121
+
122
+ settings: {
123
+ description: 'Show content settings.',
124
+ async handler(ctx) {
125
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/settings');
126
+ out(res?.data || res, ctx);
127
+ },
128
+ },
129
+
130
+ channels: {
131
+ description: 'List connected publishing channels.',
132
+ async handler(ctx) {
133
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/channels');
134
+ out(res?.data || res, ctx);
135
+ },
136
+ },
137
+
138
+ assets: {
139
+ description: 'List the asset library.',
140
+ async handler(ctx) {
141
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/assets');
142
+ out(res?.data || res, ctx);
143
+ },
144
+ },
145
+
146
+ policy: {
147
+ description: 'Show the content policy (add --regenerate to rebuild it).',
148
+ flags: [{ name: 'regenerate', boolean: true, description: 'Regenerate the policy' }],
149
+ async handler(ctx) {
150
+ if (ctx.bool('regenerate')) {
151
+ const res = await apiRequest(ctx.profileName, 'POST', '/studio/policy/regenerate', { body: {} });
152
+ if (ctx.json) { out(res, ctx); return; }
153
+ info(`✓ Content policy regeneration triggered. ${res?.message || ''}`);
154
+ return;
155
+ }
156
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/policy');
157
+ out(res?.data || res, ctx);
158
+ },
159
+ },
160
+
161
+ retry: {
162
+ description: 'Retry a failed Studio post.',
163
+ usage: 'studio retry <postId>',
164
+ async handler(ctx) {
165
+ const id = ctx.arg(0);
166
+ if (!id) throw new Error('Usage: oppira studio retry <postId>');
167
+ const res = await apiRequest(ctx.profileName, 'POST', `/studio/posts/${id}/retry`, { body: {} });
168
+ if (ctx.json) { out(res, ctx); return; }
169
+ info(`✓ Retry requested for post ${id}. ${res?.message || ''}`);
170
+ },
171
+ },
172
+
173
+ 'generate-image': {
174
+ description: 'Generate the image for a Studio post.',
175
+ usage: 'studio generate-image <postId>',
176
+ async handler(ctx) {
177
+ const id = ctx.arg(0);
178
+ if (!id) throw new Error('Usage: oppira studio generate-image <postId>');
179
+ const res = await apiRequest(ctx.profileName, 'POST', `/studio/posts/${id}/generate-image`, { body: {} });
180
+ if (ctx.json) { out(res, ctx); return; }
181
+ info(`✓ Image generation triggered for post ${id}. ${res?.message || ''}`);
182
+ },
183
+ },
184
+ },
185
+ };
package/src/config.js ADDED
@@ -0,0 +1,97 @@
1
+ // Config + credential store under ~/.oppira. Config (non-secret: api url,
2
+ // default output) and credentials (tokens, api key) live in separate files so
3
+ // the credential file can be locked down to 0600. Everything is keyed by
4
+ // profile, so one install can talk to prod + staging + multiple accounts.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+
10
+ const CONFIG_DIR = process.env.OPPIRA_CONFIG_DIR || path.join(os.homedir(), '.oppira');
11
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
12
+ const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json');
13
+
14
+ const DEFAULT_API_URL = process.env.OPPIRA_API_URL || 'http://localhost:3008';
15
+
16
+ function ensureDir() {
17
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
18
+ }
19
+
20
+ function readJson(file, fallback) {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
23
+ } catch {
24
+ return fallback;
25
+ }
26
+ }
27
+
28
+ export function loadConfig() {
29
+ const cfg = readJson(CONFIG_FILE, null);
30
+ if (cfg && cfg.profiles) return cfg;
31
+ return {
32
+ currentProfile: 'default',
33
+ profiles: { default: { apiUrl: DEFAULT_API_URL, output: 'table' } },
34
+ };
35
+ }
36
+
37
+ export function saveConfig(cfg) {
38
+ ensureDir();
39
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
40
+ }
41
+
42
+ function loadCredentials() {
43
+ return readJson(CREDENTIALS_FILE, {});
44
+ }
45
+
46
+ function saveCredentials(creds) {
47
+ ensureDir();
48
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2));
49
+ try { fs.chmodSync(CREDENTIALS_FILE, 0o600); } catch { /* best-effort on Windows */ }
50
+ }
51
+
52
+ // Resolve the active profile name: --profile flag > env > config current.
53
+ export function resolveProfileName(flags = {}) {
54
+ return flags.profile || process.env.OPPIRA_PROFILE || loadConfig().currentProfile || 'default';
55
+ }
56
+
57
+ export function getProfile(name) {
58
+ const cfg = loadConfig();
59
+ const profileName = name || cfg.currentProfile || 'default';
60
+ const profile = cfg.profiles[profileName] || { apiUrl: DEFAULT_API_URL, output: 'table' };
61
+ return { name: profileName, ...profile };
62
+ }
63
+
64
+ export function setProfileValue(name, key, value) {
65
+ const cfg = loadConfig();
66
+ if (!cfg.profiles[name]) cfg.profiles[name] = { apiUrl: DEFAULT_API_URL, output: 'table' };
67
+ cfg.profiles[name][key] = value;
68
+ saveConfig(cfg);
69
+ return cfg.profiles[name];
70
+ }
71
+
72
+ export function useProfile(name) {
73
+ const cfg = loadConfig();
74
+ if (!cfg.profiles[name]) cfg.profiles[name] = { apiUrl: DEFAULT_API_URL, output: 'table' };
75
+ cfg.currentProfile = name;
76
+ saveConfig(cfg);
77
+ return cfg;
78
+ }
79
+
80
+ export function getCredentials(profileName) {
81
+ return loadCredentials()[profileName] || {};
82
+ }
83
+
84
+ export function setCredentials(profileName, patch) {
85
+ const creds = loadCredentials();
86
+ creds[profileName] = { ...(creds[profileName] || {}), ...patch };
87
+ saveCredentials(creds);
88
+ return creds[profileName];
89
+ }
90
+
91
+ export function clearCredentials(profileName) {
92
+ const creds = loadCredentials();
93
+ delete creds[profileName];
94
+ saveCredentials(creds);
95
+ }
96
+
97
+ export const paths = { CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE };
@@ -0,0 +1,121 @@
1
+ // Tiny dependency-free command framework. Enough structure for a full
2
+ // subcommand tree (`oppira <group> <command> [args] [--flags]`) plus top-level
3
+ // commands (`oppira ask "..."`), with generated help and typed flag parsing —
4
+ // without pulling in commander/yargs, so the CLI runs with just `node`.
5
+
6
+ const GLOBAL_BOOLEAN_FLAGS = new Set(['json', 'local', 'help', 'version', 'yes']);
7
+
8
+ // Parse argv into { positionals, flags }. Rules:
9
+ // --key value → flags.key = "value" (unless value looks like a flag)
10
+ // --key=value → flags.key = "value"
11
+ // --flag → flags.flag = true (boolean; also when next is a flag)
12
+ // -k → treated as long flag "k"
13
+ // Known booleans (global + per-command) never consume the next token.
14
+ export function parseArgs(tokens, booleanFlags = new Set()) {
15
+ const positionals = [];
16
+ const flags = {};
17
+ const bools = new Set([...GLOBAL_BOOLEAN_FLAGS, ...booleanFlags]);
18
+
19
+ for (let i = 0; i < tokens.length; i++) {
20
+ const tok = tokens[i];
21
+ if (tok === '--') { positionals.push(...tokens.slice(i + 1)); break; }
22
+ if (tok.startsWith('--') || (tok.startsWith('-') && tok.length === 2 && Number.isNaN(Number(tok)))) {
23
+ const raw = tok.replace(/^--?/, '');
24
+ const eq = raw.indexOf('=');
25
+ if (eq !== -1) {
26
+ flags[raw.slice(0, eq)] = raw.slice(eq + 1);
27
+ continue;
28
+ }
29
+ const key = raw;
30
+ const next = tokens[i + 1];
31
+ if (bools.has(key) || next === undefined || next.startsWith('--')) {
32
+ flags[key] = true;
33
+ } else {
34
+ flags[key] = next;
35
+ i++;
36
+ }
37
+ continue;
38
+ }
39
+ positionals.push(tok);
40
+ }
41
+ return { positionals, flags };
42
+ }
43
+
44
+ // A small helper the handlers receive, so they don't reach into raw flags.
45
+ export function makeContext({ positionals, flags }) {
46
+ return {
47
+ positionals,
48
+ flags,
49
+ arg(i) { return positionals[i]; },
50
+ flag(name, fallback = undefined) {
51
+ return Object.prototype.hasOwnProperty.call(flags, name) ? flags[name] : fallback;
52
+ },
53
+ bool(name) { return flags[name] === true || flags[name] === 'true'; },
54
+ get json() { return this.bool('json'); },
55
+ get useLocal() { return this.bool('local'); },
56
+ };
57
+ }
58
+
59
+ function pad(str, width) { return String(str).padEnd(width, ' '); }
60
+
61
+ export function renderProgramHelp(program) {
62
+ const lines = [];
63
+ lines.push(`${program.name} v${program.version} — ${program.description}`);
64
+ lines.push('');
65
+ lines.push('Usage:');
66
+ lines.push(` ${program.name} <command> [subcommand] [args] [--flags]`);
67
+ lines.push('');
68
+ lines.push('Command groups:');
69
+ const groupWidth = Math.max(...Object.keys(program.groups).map((g) => g.length)) + 2;
70
+ for (const [name, group] of Object.entries(program.groups)) {
71
+ lines.push(` ${pad(name, groupWidth)}${group.description}`);
72
+ }
73
+ if (program.topLevel && Object.keys(program.topLevel).length) {
74
+ lines.push('');
75
+ lines.push('Top-level commands:');
76
+ const tlWidth = Math.max(...Object.keys(program.topLevel).map((c) => c.length)) + 2;
77
+ for (const [name, cmd] of Object.entries(program.topLevel)) {
78
+ lines.push(` ${pad(name, tlWidth)}${cmd.description}`);
79
+ }
80
+ }
81
+ lines.push('');
82
+ lines.push('Global flags:');
83
+ lines.push(' --json Machine-readable JSON output (scripting / agents)');
84
+ lines.push(' --local Run against MongoDB + engines directly (ops mode; needs repo + MONGO_URI)');
85
+ lines.push(' --profile <name> Use a named config profile (default: current)');
86
+ lines.push(' --help, --version');
87
+ lines.push('');
88
+ lines.push(`Run "${program.name} <group> --help" to list a group's commands.`);
89
+ return lines.join('\n');
90
+ }
91
+
92
+ export function renderGroupHelp(program, groupName, group) {
93
+ const lines = [];
94
+ lines.push(`${program.name} ${groupName} — ${group.description}`);
95
+ lines.push('');
96
+ lines.push('Commands:');
97
+ const width = Math.max(...Object.keys(group.commands).map((c) => c.length)) + 2;
98
+ for (const [name, cmd] of Object.entries(group.commands)) {
99
+ lines.push(` ${pad(name, width)}${cmd.description || ''}`);
100
+ }
101
+ return lines.join('\n');
102
+ }
103
+
104
+ export function renderCommandHelp(program, path, cmd) {
105
+ const lines = [];
106
+ lines.push(`${program.name} ${path} — ${cmd.description || ''}`);
107
+ if (cmd.usage) { lines.push(''); lines.push('Usage:'); lines.push(` ${program.name} ${cmd.usage}`); }
108
+ if (cmd.flags && cmd.flags.length) {
109
+ lines.push('');
110
+ lines.push('Flags:');
111
+ const width = Math.max(...cmd.flags.map((f) => f.name.length)) + 4;
112
+ for (const f of cmd.flags) lines.push(` --${pad(f.name, width)}${f.description || ''}`);
113
+ }
114
+ return lines.join('\n');
115
+ }
116
+
117
+ export function collectBooleanFlags(cmd) {
118
+ const set = new Set();
119
+ for (const f of cmd?.flags || []) if (f.boolean) set.add(f.name);
120
+ return set;
121
+ }
package/src/http.js ADDED
@@ -0,0 +1,157 @@
1
+ // Authenticated HTTP client for the Oppira REST API. Handles the JWT dance
2
+ // (15-min access token + refresh) transparently: on an expired-token response
3
+ // it refreshes once using the stored refresh token and retries. Falls back to
4
+ // the durable `ca_` API key when no JWT session is present (works on /mcp today,
5
+ // and on REST once the auth enabler ships — see the CLI plan).
6
+
7
+ import { getProfile, getCredentials, setCredentials } from './config.js';
8
+
9
+ export class ApiError extends Error {
10
+ constructor(message, { status, body } = {}) {
11
+ super(message);
12
+ this.name = 'ApiError';
13
+ this.status = status;
14
+ this.body = body;
15
+ }
16
+ }
17
+
18
+ function joinUrl(base, pathname) {
19
+ return `${base.replace(/\/$/, '')}/${pathname.replace(/^\//, '')}`;
20
+ }
21
+
22
+ // Extract the refreshToken value from a Set-Cookie header list.
23
+ function extractRefreshCookie(res) {
24
+ const cookies = typeof res.headers.getSetCookie === 'function'
25
+ ? res.headers.getSetCookie()
26
+ : [res.headers.get('set-cookie')].filter(Boolean);
27
+ for (const c of cookies) {
28
+ const m = /(?:^|;\s*)refreshToken=([^;]+)/.exec(c);
29
+ if (m) return decodeURIComponent(m[1]);
30
+ }
31
+ return null;
32
+ }
33
+
34
+ async function parseBody(res) {
35
+ const text = await res.text();
36
+ if (!text) return null;
37
+ try { return JSON.parse(text); } catch { return text; }
38
+ }
39
+
40
+ export async function login(profileName, email, password) {
41
+ const profile = getProfile(profileName);
42
+ const res = await fetch(joinUrl(profile.apiUrl, '/users/login'), {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ email, password }),
46
+ });
47
+ const body = await parseBody(res);
48
+ if (!res.ok) {
49
+ throw new ApiError(body?.message || `Login failed (${res.status})`, { status: res.status, body });
50
+ }
51
+ const refreshToken = extractRefreshCookie(res);
52
+ const creds = {
53
+ accessToken: body.accessToken,
54
+ refreshToken,
55
+ email: body.user?.email || email,
56
+ apiKey: body.user?.apiKey || undefined,
57
+ };
58
+ setCredentials(profile.name, creds);
59
+ return { user: body.user, accessToken: body.accessToken };
60
+ }
61
+
62
+ async function refreshAccessToken(profile) {
63
+ const creds = getCredentials(profile.name);
64
+ if (!creds.refreshToken) return null;
65
+ const res = await fetch(joinUrl(profile.apiUrl, '/users/refresh-token'), {
66
+ method: 'POST',
67
+ headers: {
68
+ 'Content-Type': 'application/json',
69
+ // The refresh handler reads the cookie OR body.refreshToken — send both.
70
+ Cookie: `refreshToken=${encodeURIComponent(creds.refreshToken)}`,
71
+ },
72
+ body: JSON.stringify({ refreshToken: creds.refreshToken }),
73
+ });
74
+ if (!res.ok) return null;
75
+ const body = await parseBody(res);
76
+ const rotated = extractRefreshCookie(res);
77
+ const patch = { accessToken: body.accessToken };
78
+ if (rotated) patch.refreshToken = rotated;
79
+ setCredentials(profile.name, patch);
80
+ return body.accessToken;
81
+ }
82
+
83
+ function authHeader(creds) {
84
+ if (creds.accessToken) return `Bearer ${creds.accessToken}`;
85
+ if (creds.apiKey) return `Bearer ${creds.apiKey}`;
86
+ return null;
87
+ }
88
+
89
+ function isExpiredTokenResponse(status, body) {
90
+ if (status === 401) return true;
91
+ if (status === 403 && typeof body?.message === 'string' && /expired|invalid/i.test(body.message)) return true;
92
+ return false;
93
+ }
94
+
95
+ // Core authenticated request. `pathname` is relative to the profile's apiUrl.
96
+ export async function apiRequest(profileName, method, pathname, { body, query } = {}) {
97
+ const profile = getProfile(profileName);
98
+ let creds = getCredentials(profile.name);
99
+
100
+ let url = joinUrl(profile.apiUrl, pathname);
101
+ if (query && Object.keys(query).length) {
102
+ const qs = new URLSearchParams(
103
+ Object.entries(query).filter(([, v]) => v !== undefined && v !== null)
104
+ ).toString();
105
+ if (qs) url += `?${qs}`;
106
+ }
107
+
108
+ const doFetch = async () => {
109
+ // Identify as the CLI (diagnostics / telemetry). Limits are client-agnostic.
110
+ const headers = { 'Content-Type': 'application/json', 'X-Oppira-Client': 'oppira-cli' };
111
+ const auth = authHeader(creds);
112
+ if (auth) headers.Authorization = auth;
113
+ let res;
114
+ try {
115
+ res = await fetch(url, {
116
+ method,
117
+ headers,
118
+ body: body !== undefined ? JSON.stringify(body) : undefined,
119
+ });
120
+ } catch (err) {
121
+ // Connection refused / DNS / TLS — surface something actionable instead
122
+ // of the bare "fetch failed".
123
+ throw new ApiError(
124
+ `Cannot reach ${profile.apiUrl} (profile "${profile.name}"). Is the API running? Set it with: oppira config set-url <url>`,
125
+ { status: 0, body: { cause: err?.message } }
126
+ );
127
+ }
128
+ return { res, parsed: await parseBody(res) };
129
+ };
130
+
131
+ let { res, parsed } = await doFetch();
132
+
133
+ // One transparent refresh-and-retry on an expired access token.
134
+ if (isExpiredTokenResponse(res.status, parsed) && creds.refreshToken) {
135
+ const newToken = await refreshAccessToken(profile);
136
+ if (newToken) {
137
+ creds = getCredentials(profile.name);
138
+ ({ res, parsed } = await doFetch());
139
+ }
140
+ }
141
+
142
+ if (!res.ok) {
143
+ // Preserve quota / plan-gate shapes for callers to render nicely.
144
+ const msg = parsed?.message || parsed?.error || `Request failed (${res.status})`;
145
+ throw new ApiError(msg, { status: res.status, body: parsed });
146
+ }
147
+ return parsed;
148
+ }
149
+
150
+ export function requireAuth(profileName) {
151
+ const profile = getProfile(profileName);
152
+ const creds = getCredentials(profile.name);
153
+ if (!creds.accessToken && !creds.apiKey) {
154
+ throw new ApiError(`Not logged in for profile "${profile.name}". Run: oppira auth login`);
155
+ }
156
+ return creds;
157
+ }