callkaro-cli-unofficial 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 +196 -0
- package/dist/commands/accounts.js +275 -0
- package/dist/commands/agentPhone.js +50 -0
- package/dist/commands/agentVersions.js +228 -0
- package/dist/commands/agents.js +455 -0
- package/dist/commands/analytics.js +77 -0
- package/dist/commands/analyzers.js +130 -0
- package/dist/commands/auditStrategies.js +178 -0
- package/dist/commands/batches.js +189 -0
- package/dist/commands/calls.js +193 -0
- package/dist/commands/chatAgents.js +251 -0
- package/dist/commands/jsonInput.js +56 -0
- package/dist/commands/kpi.js +681 -0
- package/dist/commands/models.js +99 -0
- package/dist/commands/numbers.js +154 -0
- package/dist/commands/ongoing.js +39 -0
- package/dist/commands/secrets.js +124 -0
- package/dist/commands/setJson.js +42 -0
- package/dist/commands/simulations.js +139 -0
- package/dist/commands/skills.js +318 -0
- package/dist/commands/transcribers.js +95 -0
- package/dist/commands/update.js +86 -0
- package/dist/commands/voices.js +211 -0
- package/dist/data/catalog.json +1978 -0
- package/dist/endpoints.json +8 -0
- package/dist/index.js +684 -0
- package/dist/lib/agentImport.js +60 -0
- package/dist/lib/agentUpdate.js +262 -0
- package/dist/lib/api.js +287 -0
- package/dist/lib/browserAuth.js +93 -0
- package/dist/lib/catalog.js +136 -0
- package/dist/lib/config.js +354 -0
- package/dist/lib/dashboardApi.js +197 -0
- package/dist/lib/dashboardAuth.js +339 -0
- package/dist/lib/dbFields.js +30 -0
- package/dist/lib/logQuery.js +140 -0
- package/dist/lib/looseJson.js +198 -0
- package/dist/lib/session.js +17 -0
- package/dist/lib/types.js +15 -0
- package/dist/lib/ui.js +91 -0
- package/dist/lib/versions.js +19 -0
- package/package.json +52 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.browserAuth = browserAuth;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const api_1 = require("./api");
|
|
6
|
+
const config_1 = require("./config");
|
|
7
|
+
const ui_1 = require("./ui");
|
|
8
|
+
const types_1 = require("./types");
|
|
9
|
+
/** Open a URL in the user's default browser (no extra dependency). */
|
|
10
|
+
function openBrowser(url) {
|
|
11
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
12
|
+
const args = process.platform === 'win32' ? ['', url] : [url];
|
|
13
|
+
try {
|
|
14
|
+
(0, child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
/* fall back to the printed link below */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
/**
|
|
22
|
+
* Browser sign-in handshake — no password is ever typed into the terminal.
|
|
23
|
+
*
|
|
24
|
+
* We start a session, open the web app at /cli-login?code=…, then poll until the
|
|
25
|
+
* page approves it. The poll secret never leaves this process, so the visible
|
|
26
|
+
* code alone can't be used to grab the token.
|
|
27
|
+
*
|
|
28
|
+
* `mode` only changes where we land the user first: sign-in vs sign-up (the web
|
|
29
|
+
* page offers Google and email on both).
|
|
30
|
+
*/
|
|
31
|
+
async function browserAuth(mode = 'login') {
|
|
32
|
+
const { api } = (0, config_1.getClient)();
|
|
33
|
+
let session;
|
|
34
|
+
try {
|
|
35
|
+
session = await api.browserStart();
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
(0, ui_1.fail)(err instanceof Error ? err.message : 'Could not start browser login');
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// Always land on /cli-login — that page owns the routing (it sends the user to
|
|
42
|
+
// /login or /signup if needed and returns here), so there is exactly one entry
|
|
43
|
+
// URL and no redirect-param handling duplicated in the CLI.
|
|
44
|
+
const url = `${(0, config_1.webUrl)()}/cli-login?code=${encodeURIComponent(session.code)}` +
|
|
45
|
+
(mode === 'signup' ? '&mode=signup' : '');
|
|
46
|
+
(0, ui_1.info)('Opening your browser to finish signing in…');
|
|
47
|
+
(0, ui_1.info)(`If it doesn't open, paste this link:\n ${url}\n`);
|
|
48
|
+
openBrowser(url);
|
|
49
|
+
const deadline = Date.now() + (session.expiresInSeconds || 600) * 1000;
|
|
50
|
+
(0, ui_1.info)('Waiting for you to complete sign-in in the browser… (Ctrl+C to cancel)');
|
|
51
|
+
let transientFailures = 0;
|
|
52
|
+
while (Date.now() < deadline) {
|
|
53
|
+
await sleep(2000);
|
|
54
|
+
try {
|
|
55
|
+
const res = await api.browserPoll(session.code, session.pollSecret);
|
|
56
|
+
if (res.status === 'approved') {
|
|
57
|
+
// Stored under its own email only — never merged into the active
|
|
58
|
+
// account. No email, no save: a token filed under the wrong account
|
|
59
|
+
// would silently replace that account's session.
|
|
60
|
+
let user = res.user;
|
|
61
|
+
if (!user?.email && res.token) {
|
|
62
|
+
try {
|
|
63
|
+
const me = await new api_1.Api((0, config_1.backendUrl)(), (0, config_1.callUrl)(), res.token).me();
|
|
64
|
+
user = me?.user || me;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* reported below */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!res.token || !user?.email) {
|
|
71
|
+
(0, ui_1.fail)('Sign-in was approved but the server did not return the account email, so nothing was saved. Run the command again.');
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
(0, ui_1.success)((0, config_1.saveLogin)(user.email, { token: res.token, user }));
|
|
75
|
+
return user;
|
|
76
|
+
}
|
|
77
|
+
transientFailures = 0; // a clean "pending" clears the streak
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const status = err instanceof types_1.ApiError ? err.status : -1;
|
|
81
|
+
// A dropped connection or a server hiccup shouldn't end a sign-in the user
|
|
82
|
+
// is still completing — keep polling. Only definitive answers are fatal:
|
|
83
|
+
// 404 expired, 403 wrong secret, 400 already used.
|
|
84
|
+
const transient = status === 0 || status >= 500;
|
|
85
|
+
if (transient && ++transientFailures < 10)
|
|
86
|
+
continue;
|
|
87
|
+
(0, ui_1.fail)(err instanceof Error ? err.message : 'Browser login failed');
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
(0, ui_1.fail)('Timed out waiting for sign-in. Run the command again.');
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.LLM_MODELS = exports.MURF_STYLES = exports.TRANSCRIBER_PROVIDERS = exports.VOICE_PROVIDERS = void 0;
|
|
7
|
+
exports.applyLiveCatalog = applyLiveCatalog;
|
|
8
|
+
exports.findVoiceProvider = findVoiceProvider;
|
|
9
|
+
exports.findTranscriberProvider = findTranscriberProvider;
|
|
10
|
+
exports.langMatches = langMatches;
|
|
11
|
+
exports.visibleVoiceProviders = visibleVoiceProviders;
|
|
12
|
+
exports.visibleTranscriberProviders = visibleTranscriberProviders;
|
|
13
|
+
const catalog_json_1 = __importDefault(require("../data/catalog.json"));
|
|
14
|
+
const hydrate = (p) => ({
|
|
15
|
+
name: p.name,
|
|
16
|
+
models: (p.models || []).map((m) => ({ name: m.name, languages: m.languages || [] })),
|
|
17
|
+
voiceSource: p.voiceSource === 'api' ? 'api' : p.voiceSource === 'static' ? 'static' : null,
|
|
18
|
+
languageKind: p.languageKind === 'array' ? 'array' : p.languageKind === 'string' ? 'string' : null,
|
|
19
|
+
languages: p.languages || [],
|
|
20
|
+
fields: p.fields || [],
|
|
21
|
+
gate: p.gate === 'admin' ? 'admin' : null,
|
|
22
|
+
notes: p.notes || null,
|
|
23
|
+
voices: p.voices || [],
|
|
24
|
+
});
|
|
25
|
+
const raw = catalog_json_1.default;
|
|
26
|
+
exports.VOICE_PROVIDERS = raw.voice.map(hydrate);
|
|
27
|
+
exports.TRANSCRIBER_PROVIDERS = raw.transcriber.map(hydrate);
|
|
28
|
+
exports.MURF_STYLES = raw.murfStyles;
|
|
29
|
+
/**
|
|
30
|
+
* Per-slot LLM model lists (`agent`, `post-call`, ...), keyed by slot name.
|
|
31
|
+
*
|
|
32
|
+
* These are scoped, not flat: `post-call` excludes `gpt-4o` and `o4-mini` while
|
|
33
|
+
* `agent` includes both, so a single list validates one field wrongly or the
|
|
34
|
+
* other. Kept beside the provider tables so `npm run catalog:check` compares all
|
|
35
|
+
* three against the server rather than leaving LLM models to a hand-written list.
|
|
36
|
+
*/
|
|
37
|
+
exports.LLM_MODELS = raw.llmModels || {};
|
|
38
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
39
|
+
/** Live model entries arrive as plain strings; be tolerant of `{name}` too. */
|
|
40
|
+
const modelName = (m) => typeof m === 'string' ? m : String(m?.name ?? m?.model ?? m?.id ?? '');
|
|
41
|
+
/**
|
|
42
|
+
* Overlay the server's own model lists onto the built-in providers, in place.
|
|
43
|
+
*
|
|
44
|
+
* Mutating rather than reassigning matters: `agentUpdate.ts` and the voice and
|
|
45
|
+
* transcriber commands hold references to these exact Provider objects, so
|
|
46
|
+
* replacing the arrays would leave them validating against the stale copy.
|
|
47
|
+
*
|
|
48
|
+
* Only `models` is taken from the live payload. Everything else the CLI needs —
|
|
49
|
+
* `fields`, `gate`, `languageKind`, static `voices` — exists only in the bundle,
|
|
50
|
+
* so a provider present in both keeps that metadata and gains the live models.
|
|
51
|
+
*
|
|
52
|
+
* A provider the server no longer lists (Groq) is left in place but flagged, so
|
|
53
|
+
* `cku voices` can say it is not currently served rather than silently offering
|
|
54
|
+
* a dead choice.
|
|
55
|
+
*/
|
|
56
|
+
function applyLiveCatalog(catalogs) {
|
|
57
|
+
const summary = { voices: 0, transcribers: 0 };
|
|
58
|
+
const overlay = (targets, live) => {
|
|
59
|
+
if (!Array.isArray(live) || !live.length)
|
|
60
|
+
return; // leave this list entirely alone
|
|
61
|
+
const byName = new Map(targets.map((p) => [norm(p.name), p]));
|
|
62
|
+
for (const entry of live) {
|
|
63
|
+
const name = String(entry?.provider ?? '');
|
|
64
|
+
if (!name)
|
|
65
|
+
continue;
|
|
66
|
+
const provider = byName.get(norm(name));
|
|
67
|
+
if (!provider)
|
|
68
|
+
continue; // a provider the bundle never saw; do not invent metadata
|
|
69
|
+
const models = (Array.isArray(entry.models) ? entry.models : [])
|
|
70
|
+
.map(modelName)
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.map((n) => ({ name: n, languages: provider.models.find((m) => norm(m.name) === norm(n))?.languages || [] }));
|
|
73
|
+
// Keep the live list even when empty: Azure and Open AI legitimately have
|
|
74
|
+
// no selectable models, and falling back would resurrect withdrawn ones.
|
|
75
|
+
provider.models = models;
|
|
76
|
+
provider.servedBy = true;
|
|
77
|
+
}
|
|
78
|
+
// Anything not named in a list we actually received is no longer served.
|
|
79
|
+
// Runs here rather than at the end for both lists, so a call that omits one
|
|
80
|
+
// of them leaves that one untouched instead of flagging everything in it.
|
|
81
|
+
for (const p of targets) {
|
|
82
|
+
if (p.servedBy === undefined)
|
|
83
|
+
p.servedBy = false;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
overlay(exports.VOICE_PROVIDERS, catalogs?.ttsProviders);
|
|
87
|
+
overlay(exports.TRANSCRIBER_PROVIDERS, catalogs?.sttProviders);
|
|
88
|
+
summary.voices = exports.VOICE_PROVIDERS.filter((p) => p.servedBy === true).length;
|
|
89
|
+
summary.transcribers = exports.TRANSCRIBER_PROVIDERS.filter((p) => p.servedBy === true).length;
|
|
90
|
+
return summary;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Case- and punctuation-insensitive lookup, so "elevenlabs", "Eleven Labs" and
|
|
94
|
+
* "eleven-labs" all resolve. Providers store the canonical spelling.
|
|
95
|
+
*/
|
|
96
|
+
function findVoiceProvider(name) {
|
|
97
|
+
const n = norm(name);
|
|
98
|
+
return exports.VOICE_PROVIDERS.find((p) => norm(p.name) === n);
|
|
99
|
+
}
|
|
100
|
+
function findTranscriberProvider(name) {
|
|
101
|
+
const n = norm(name);
|
|
102
|
+
return exports.TRANSCRIBER_PROVIDERS.find((p) => norm(p.name) === n);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Language match. Provider tables mix bare codes ('hi') and locales ('hi-IN'),
|
|
106
|
+
* so accept either direction: they agree, or one is the other's base code.
|
|
107
|
+
*/
|
|
108
|
+
function langMatches(query, providerLangs) {
|
|
109
|
+
const q = norm(query);
|
|
110
|
+
if (!q)
|
|
111
|
+
return true;
|
|
112
|
+
return providerLangs.some((lang) => {
|
|
113
|
+
const l = norm(lang);
|
|
114
|
+
return l === q || l.split('-')[0] === q || q.split('-')[0] === l.split('-')[0];
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Providers visible for an account. `opts.all` includes admin-gated ones;
|
|
119
|
+
* otherwise they are hidden unless the account is authorized for them.
|
|
120
|
+
*/
|
|
121
|
+
function visibleVoiceProviders(opts = {}) {
|
|
122
|
+
if (opts.allowed && opts.allowed.length) {
|
|
123
|
+
return exports.VOICE_PROVIDERS.filter((p) => opts.allowed.some((a) => norm(a) === norm(p.name)));
|
|
124
|
+
}
|
|
125
|
+
if (opts.all)
|
|
126
|
+
return exports.VOICE_PROVIDERS;
|
|
127
|
+
return exports.VOICE_PROVIDERS.filter((p) => p.gate !== 'admin');
|
|
128
|
+
}
|
|
129
|
+
function visibleTranscriberProviders(opts = {}) {
|
|
130
|
+
if (opts.allowed && opts.allowed.length) {
|
|
131
|
+
return exports.TRANSCRIBER_PROVIDERS.filter((p) => opts.allowed.some((a) => norm(a) === norm(p.name)));
|
|
132
|
+
}
|
|
133
|
+
if (opts.all)
|
|
134
|
+
return exports.TRANSCRIBER_PROVIDERS;
|
|
135
|
+
return exports.TRANSCRIBER_PROVIDERS.filter((p) => p.gate !== 'admin');
|
|
136
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.accountsDir = exports.OFFICIAL_SKILLS_REPO = exports.configPath = exports.CONFIG_DIR = void 0;
|
|
7
|
+
exports.backendUrl = backendUrl;
|
|
8
|
+
exports.callUrl = callUrl;
|
|
9
|
+
exports.webUrl = webUrl;
|
|
10
|
+
exports.skillsRepo = skillsRepo;
|
|
11
|
+
exports.skillsRepoFor = skillsRepoFor;
|
|
12
|
+
exports.skillsBranch = skillsBranch;
|
|
13
|
+
exports.slugFor = slugFor;
|
|
14
|
+
exports.activeAccount = activeAccount;
|
|
15
|
+
exports.projectAccount = projectAccount;
|
|
16
|
+
exports.setActiveAccount = setActiveAccount;
|
|
17
|
+
exports.clearActiveAccount = clearActiveAccount;
|
|
18
|
+
exports.bindProjectAccount = bindProjectAccount;
|
|
19
|
+
exports.unbindProjectAccount = unbindProjectAccount;
|
|
20
|
+
exports.accountSource = accountSource;
|
|
21
|
+
exports.listAccounts = listAccounts;
|
|
22
|
+
exports.loadAccount = loadAccount;
|
|
23
|
+
exports.saveAccount = saveAccount;
|
|
24
|
+
exports.saveDashboardSession = saveDashboardSession;
|
|
25
|
+
exports.clearDashboardSession = clearDashboardSession;
|
|
26
|
+
exports.removeAccount = removeAccount;
|
|
27
|
+
exports.loadConfig = loadConfig;
|
|
28
|
+
exports.clearSession = clearSession;
|
|
29
|
+
exports.saveLogin = saveLogin;
|
|
30
|
+
exports.getClient = getClient;
|
|
31
|
+
const fs_1 = require("fs");
|
|
32
|
+
const os_1 = require("os");
|
|
33
|
+
const path_1 = require("path");
|
|
34
|
+
const api_1 = require("./api");
|
|
35
|
+
const endpoints_json_1 = __importDefault(require("../endpoints.json"));
|
|
36
|
+
// Endpoints live in a committed, shipped file (NOT .env) — the platform's
|
|
37
|
+
// packaging doesn't allow .env. Edit endpoints.json to change where the CLI
|
|
38
|
+
// points: `backendUrl` for everything, `callUrl` for placing calls (the call
|
|
39
|
+
// cloud may move independently later).
|
|
40
|
+
//
|
|
41
|
+
// The config dir is `callkaro-unofficial`, not `callkaro`, so this fork and
|
|
42
|
+
// @callkaro-official/cli keep separate sessions. Sharing one file means the
|
|
43
|
+
// last `ck` or `cku login` overwrites the other's token, and `ck logout` would
|
|
44
|
+
// clear the fork's session too.
|
|
45
|
+
//
|
|
46
|
+
// CK_CONFIG_DIR relocates the whole thing, which is what the tests use to stay
|
|
47
|
+
// out of the real ~/.config. It is read once at load: changing it mid-process
|
|
48
|
+
// would leave `accountsDir` and `activePath` pointing at the old location.
|
|
49
|
+
exports.CONFIG_DIR = process.env.CK_CONFIG_DIR || (0, path_1.join)((0, os_1.homedir)(), '.config', 'callkaro-unofficial');
|
|
50
|
+
exports.configPath = (0, path_1.join)(exports.CONFIG_DIR, 'config.json');
|
|
51
|
+
const trim = (u) => (u || '').replace(/\/+$/, '');
|
|
52
|
+
/** Base URL for all backend operations (from endpoints.json). */
|
|
53
|
+
function backendUrl() {
|
|
54
|
+
return trim(process.env.CK_BACKEND_URL || endpoints_json_1.default.backendUrl);
|
|
55
|
+
}
|
|
56
|
+
/** Base URL used to place calls — the "call cloud" (from endpoints.json). */
|
|
57
|
+
function callUrl() {
|
|
58
|
+
return trim(endpoints_json_1.default.callUrl || endpoints_json_1.default.backendUrl);
|
|
59
|
+
}
|
|
60
|
+
/** Web app URL — where the browser is sent for sign-in (from endpoints.json). */
|
|
61
|
+
function webUrl() {
|
|
62
|
+
return trim(endpoints_json_1.default.webUrl || endpoints_json_1.default.backendUrl);
|
|
63
|
+
}
|
|
64
|
+
/** GitHub repo holding this fork's skill pack — skills ship separately from the CLI. */
|
|
65
|
+
function skillsRepo() {
|
|
66
|
+
return endpoints_json_1.default.skillsRepo || 'TejasLamba2006/callkaro-skills';
|
|
67
|
+
}
|
|
68
|
+
/** The upstream pack, for `cku skills install --official`. */
|
|
69
|
+
exports.OFFICIAL_SKILLS_REPO = 'Mail-Daddy-AI/callkaro-skills';
|
|
70
|
+
/** Which pack to fetch. `official` opts into upstream; default is this fork's. */
|
|
71
|
+
function skillsRepoFor(official) {
|
|
72
|
+
return official ? exports.OFFICIAL_SKILLS_REPO : skillsRepo();
|
|
73
|
+
}
|
|
74
|
+
function skillsBranch() {
|
|
75
|
+
return endpoints_json_1.default.skillsBranch || 'main';
|
|
76
|
+
}
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Multi-account
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
//
|
|
81
|
+
// One file per account, so signing into a second account never disturbs the
|
|
82
|
+
// first. The *selected* account is the only mutable state, and it lives in
|
|
83
|
+
// `active.json` — a pointer, not a credential. Losing that file downgrades to
|
|
84
|
+
// the single-account behaviour rather than losing sessions.
|
|
85
|
+
//
|
|
86
|
+
// ~/.config/callkaro-unofficial/
|
|
87
|
+
// accounts/
|
|
88
|
+
// <slug>.json one session each
|
|
89
|
+
// active.json { "account": "<slug>" }
|
|
90
|
+
//
|
|
91
|
+
// A slug is the email with non-alphanumerics collapsed to '-', so
|
|
92
|
+
// tejas@acme.com -> tejas-acme-com. The local part alone is not unique enough.
|
|
93
|
+
exports.accountsDir = (0, path_1.join)(exports.CONFIG_DIR, 'accounts');
|
|
94
|
+
const activePath = (0, path_1.join)(exports.CONFIG_DIR, 'active.json');
|
|
95
|
+
/** Per-project binding file. Checked into git on purpose — a shared repo then
|
|
96
|
+
* gives every clone the same account, without anyone running a setup step. */
|
|
97
|
+
const PROJECT_CONFIG = '.cku.json';
|
|
98
|
+
/** Filesystem-safe slug for an email address. */
|
|
99
|
+
function slugFor(email) {
|
|
100
|
+
const slug = email.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
101
|
+
return slug || 'account';
|
|
102
|
+
}
|
|
103
|
+
function readJson(path, fallback) {
|
|
104
|
+
if (!(0, fs_1.existsSync)(path))
|
|
105
|
+
return fallback;
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse((0, fs_1.readFileSync)(path, 'utf8'));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return fallback;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function writeJson(path, value) {
|
|
114
|
+
(0, fs_1.mkdirSync)((0, path_1.join)(path, '..'), { recursive: true });
|
|
115
|
+
(0, fs_1.writeFileSync)(path, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
116
|
+
try {
|
|
117
|
+
(0, fs_1.chmodSync)(path, 0o600);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* best effort on platforms without POSIX perms */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The account a command acts on. Resolution order:
|
|
125
|
+
* 1. CK_ACCOUNT env var — explicit, per-invocation
|
|
126
|
+
* 2. .cku.json in the current directory (or the nearest parent) — per-project
|
|
127
|
+
* 3. the global pointer in active.json
|
|
128
|
+
* 4. undefined — no session
|
|
129
|
+
*
|
|
130
|
+
* Step 2 is what makes parallel agents safe. Two agents working in two folders
|
|
131
|
+
* bound to two accounts each resolve their own binding, so neither can change
|
|
132
|
+
* what the other sees. The global pointer is only a fallback for when no
|
|
133
|
+
* project is bound, and it is the one thing two agents must not fight over —
|
|
134
|
+
* prefer `cku accounts bind` over `cku accounts use` for agent work.
|
|
135
|
+
*
|
|
136
|
+
* Callers should treat `undefined` as "no session" and say so, rather than
|
|
137
|
+
* assuming a single default account exists.
|
|
138
|
+
*/
|
|
139
|
+
function activeAccount() {
|
|
140
|
+
const override = process.env.CK_ACCOUNT;
|
|
141
|
+
if (override)
|
|
142
|
+
return slugFor(override);
|
|
143
|
+
return projectAccount() || globalAccount();
|
|
144
|
+
}
|
|
145
|
+
/** The account bound to the current directory, searching upwards for .cku.json. */
|
|
146
|
+
function projectAccount() {
|
|
147
|
+
let dir = process.cwd();
|
|
148
|
+
// Stop at the filesystem root; also stop early if we hit the user's home, so
|
|
149
|
+
// a stray ~/.cku.json can't silently capture every project.
|
|
150
|
+
const stop = (0, os_1.homedir)();
|
|
151
|
+
for (;;) {
|
|
152
|
+
const candidate = (0, path_1.join)(dir, PROJECT_CONFIG);
|
|
153
|
+
if ((0, fs_1.existsSync)(candidate)) {
|
|
154
|
+
const { account } = readJson(candidate, {});
|
|
155
|
+
if (account)
|
|
156
|
+
return account;
|
|
157
|
+
}
|
|
158
|
+
if (dir === stop)
|
|
159
|
+
return undefined;
|
|
160
|
+
const parent = (0, path_1.dirname)(dir);
|
|
161
|
+
if (parent === dir)
|
|
162
|
+
return undefined; // reached the root
|
|
163
|
+
dir = parent;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Only the global pointer (active.json), ignoring CK_ACCOUNT and .cku.json. */
|
|
167
|
+
function globalAccount() {
|
|
168
|
+
const { account } = readJson(activePath, {});
|
|
169
|
+
return account || undefined;
|
|
170
|
+
}
|
|
171
|
+
function setActiveAccount(email) {
|
|
172
|
+
const slug = slugFor(email);
|
|
173
|
+
writeJson(activePath, { account: slug });
|
|
174
|
+
return slug;
|
|
175
|
+
}
|
|
176
|
+
function clearActiveAccount() {
|
|
177
|
+
if ((0, fs_1.existsSync)(activePath))
|
|
178
|
+
writeJson(activePath, {});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Bind an account to the current directory only. Writes `.cku.json` here, so
|
|
182
|
+
* this folder — and anything under it — uses that account regardless of what
|
|
183
|
+
* the global pointer says.
|
|
184
|
+
*/
|
|
185
|
+
function bindProjectAccount(email, cwd = process.cwd()) {
|
|
186
|
+
const slug = slugFor(email);
|
|
187
|
+
writeJson((0, path_1.join)(cwd, PROJECT_CONFIG), { account: slug });
|
|
188
|
+
return slug;
|
|
189
|
+
}
|
|
190
|
+
/** Remove this directory's binding, falling back to the global pointer. */
|
|
191
|
+
function unbindProjectAccount(cwd = process.cwd()) {
|
|
192
|
+
const path = (0, path_1.join)(cwd, PROJECT_CONFIG);
|
|
193
|
+
if (!(0, fs_1.existsSync)(path))
|
|
194
|
+
return false;
|
|
195
|
+
try {
|
|
196
|
+
(0, fs_1.unlinkSync)(path);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
/** Where the current directory's binding came from — for `accounts status`. */
|
|
204
|
+
function accountSource() {
|
|
205
|
+
if (process.env.CK_ACCOUNT)
|
|
206
|
+
return 'env';
|
|
207
|
+
if (projectAccount())
|
|
208
|
+
return 'project';
|
|
209
|
+
if (globalAccount())
|
|
210
|
+
return 'global';
|
|
211
|
+
return 'none';
|
|
212
|
+
}
|
|
213
|
+
/** Every stored account, newest first — signed-out ones included (no `token`),
|
|
214
|
+
* so `logout` doesn't make an account vanish from `use`/`bind`/`remove`. */
|
|
215
|
+
function listAccounts() {
|
|
216
|
+
if (!(0, fs_1.existsSync)(exports.accountsDir))
|
|
217
|
+
return [];
|
|
218
|
+
return (0, fs_1.readdirSync)(exports.accountsDir)
|
|
219
|
+
.filter((f) => f.endsWith('.json'))
|
|
220
|
+
.map((f) => readJson((0, path_1.join)(exports.accountsDir, f), {}))
|
|
221
|
+
.filter((a) => Boolean(a.email))
|
|
222
|
+
.sort((a, b) => String(b.savedAt || '').localeCompare(String(a.savedAt || '')));
|
|
223
|
+
}
|
|
224
|
+
function accountPath(slug) {
|
|
225
|
+
return (0, path_1.join)(exports.accountsDir, `${slug}.json`);
|
|
226
|
+
}
|
|
227
|
+
function loadAccount(slug) {
|
|
228
|
+
if (!slug)
|
|
229
|
+
return {};
|
|
230
|
+
return readJson(accountPath(slug), {});
|
|
231
|
+
}
|
|
232
|
+
/** Store a session for `email` and make it the active account. */
|
|
233
|
+
function saveAccount(email, session) {
|
|
234
|
+
const slug = slugFor(email);
|
|
235
|
+
// Carry the dashboard session across. `saveLogin` passes only { token, user },
|
|
236
|
+
// so without this a later `cku login` would silently drop a dashboard session
|
|
237
|
+
// the account already holds.
|
|
238
|
+
const dashboard = session.dashboard ?? loadAccount(slug).dashboard;
|
|
239
|
+
writeJson(accountPath(slug), { ...session, email, savedAt: new Date().toISOString(), ...(dashboard ? { dashboard } : {}) });
|
|
240
|
+
setActiveAccount(email);
|
|
241
|
+
return slug;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Store the dashboard session on an account, keeping whatever else it holds.
|
|
245
|
+
* Merging matters: signing in to the dashboard must not disturb a `ck_` token
|
|
246
|
+
* already on that account, and adding a `ck_` token must not drop the
|
|
247
|
+
* dashboard session.
|
|
248
|
+
*/
|
|
249
|
+
function saveDashboardSession(email, dashboard) {
|
|
250
|
+
const slug = slugFor(email);
|
|
251
|
+
const current = loadAccount(slug);
|
|
252
|
+
// Built field by field rather than spread. A caller that accidentally passes a
|
|
253
|
+
// password (or anything else) must not have it written to disk — the guarantee
|
|
254
|
+
// that no password is ever persisted has to hold here, not at the call site.
|
|
255
|
+
const stored = {
|
|
256
|
+
email: dashboard.email || email,
|
|
257
|
+
token: dashboard.token,
|
|
258
|
+
savedAt: dashboard.savedAt || new Date().toISOString(),
|
|
259
|
+
...(dashboard.sessionToken ? { sessionToken: dashboard.sessionToken } : {}),
|
|
260
|
+
...(dashboard.userId ? { userId: dashboard.userId } : {}),
|
|
261
|
+
...(dashboard.expiresAt ? { expiresAt: dashboard.expiresAt } : {}),
|
|
262
|
+
};
|
|
263
|
+
writeJson(accountPath(slug), { ...current, email, dashboard: stored, savedAt: new Date().toISOString() });
|
|
264
|
+
return slug;
|
|
265
|
+
}
|
|
266
|
+
/** Forget only the dashboard session; the `ck_` token is left alone. */
|
|
267
|
+
function clearDashboardSession(email) {
|
|
268
|
+
const slug = slugFor(email);
|
|
269
|
+
const current = loadAccount(slug);
|
|
270
|
+
if (!current.dashboard)
|
|
271
|
+
return false;
|
|
272
|
+
const { dashboard, ...rest } = current;
|
|
273
|
+
writeJson(accountPath(slug), rest);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
function removeAccount(slug) {
|
|
277
|
+
const path = accountPath(slug);
|
|
278
|
+
if (!(0, fs_1.existsSync)(path))
|
|
279
|
+
return false;
|
|
280
|
+
// Unlink rather than write an empty file: a tokenless account would still
|
|
281
|
+
// show up in the account list.
|
|
282
|
+
try {
|
|
283
|
+
(0, fs_1.unlinkSync)(path);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
// The global pointer only: activeAccount() may be this folder's binding, and
|
|
289
|
+
// clearing on that would wipe an unrelated default (or miss a dangling one).
|
|
290
|
+
if (globalAccount() === slug)
|
|
291
|
+
clearActiveAccount();
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Session resolution
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
/**
|
|
298
|
+
* The session in effect: CK_TOKEN wins outright (automation), otherwise the
|
|
299
|
+
* active account's stored token.
|
|
300
|
+
*/
|
|
301
|
+
function loadConfig() {
|
|
302
|
+
const envToken = process.env.CK_TOKEN;
|
|
303
|
+
if (envToken)
|
|
304
|
+
return { token: envToken };
|
|
305
|
+
return loadAccount(activeAccount());
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Drop the effective account's token (and identity), keeping the account slot
|
|
309
|
+
* so it stays listed as signed out. Returns that account's email, if any.
|
|
310
|
+
*
|
|
311
|
+
* Under CK_TOKEN no stored account is in effect, so none is touched — clearing
|
|
312
|
+
* whichever one happens to be active would sign out an unrelated session.
|
|
313
|
+
*/
|
|
314
|
+
function clearSession() {
|
|
315
|
+
if (process.env.CK_TOKEN)
|
|
316
|
+
return undefined;
|
|
317
|
+
const slug = activeAccount();
|
|
318
|
+
if (!slug)
|
|
319
|
+
return undefined;
|
|
320
|
+
const path = accountPath(slug);
|
|
321
|
+
if (!(0, fs_1.existsSync)(path))
|
|
322
|
+
return undefined;
|
|
323
|
+
const { email, savedAt, dashboard } = loadAccount(slug);
|
|
324
|
+
// Dropping the ck_ token is what logout means. A dashboard session is a
|
|
325
|
+
// separate credential and is left in place — `cku firebase logout` removes it.
|
|
326
|
+
writeJson(path, { email, savedAt, ...(dashboard ? { dashboard } : {}) });
|
|
327
|
+
return email;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Store a fresh sign-in as its own account and return the line to print. Every
|
|
331
|
+
* login path goes through here: it saves ONLY the new session (never merging
|
|
332
|
+
* the active account's fields), and says honestly which account is in effect —
|
|
333
|
+
* saveAccount moves the global pointer, but CK_TOKEN, CK_ACCOUNT or a bound
|
|
334
|
+
* folder still win over it.
|
|
335
|
+
*/
|
|
336
|
+
function saveLogin(email, session) {
|
|
337
|
+
const slug = saveAccount(email, session);
|
|
338
|
+
const saved = `Signed in as ${email} — saved and set as the global default.\n`;
|
|
339
|
+
if (process.env.CK_TOKEN) {
|
|
340
|
+
return saved + ' CK_TOKEN is set and overrides every stored account; unset it to use this one.';
|
|
341
|
+
}
|
|
342
|
+
const effective = activeAccount();
|
|
343
|
+
if (effective === slug)
|
|
344
|
+
return `Signed in as ${email}. This is now the active account.`;
|
|
345
|
+
const current = loadAccount(effective).email || effective;
|
|
346
|
+
return accountSource() === 'env'
|
|
347
|
+
? saved + ` This shell still uses ${current} via CK_ACCOUNT. Unset it, or set CK_ACCOUNT=${email}, to switch.`
|
|
348
|
+
: saved + ` This folder still uses ${current} via .cku.json. Run \`cku accounts bind ${email}\` to switch.`;
|
|
349
|
+
}
|
|
350
|
+
/** Build the API client from the endpoint URLs + resolved token. */
|
|
351
|
+
function getClient() {
|
|
352
|
+
const cfg = loadConfig();
|
|
353
|
+
return { api: new api_1.Api(backendUrl(), callUrl(), cfg.token), cfg };
|
|
354
|
+
}
|