@lotics/cli 0.35.0 → 0.36.1

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/dist/config.d.ts CHANGED
@@ -1,7 +1,31 @@
1
+ /**
2
+ * A saved credential for one organization. The credential store keys these by
3
+ * `org_id`, so registering a second key for the same org updates in place
4
+ * rather than accumulating duplicates. `workspace_id` remembers the last
5
+ * workspace used in this org, so switching back lands where you left off.
6
+ */
7
+ export interface Profile {
8
+ api_key: string;
9
+ org_name: string;
10
+ workspace_id?: string;
11
+ }
12
+ /**
13
+ * The on-disk config. One schema, two file roles:
14
+ *
15
+ * - **Global** (`~/.lotics/config.json`) is the credential store: `profiles`
16
+ * (org_id → key) plus the `active_org` pointer, the account `email`, and the
17
+ * update-check cache. This is the only place API keys live.
18
+ * - **Local** (`.lotics/config.json` in a project/worktree) is a pin: an
19
+ * `active_org` pointer (+ optional `workspace_id`) whose key is resolved from
20
+ * the global store. It holds no key and never needs `profiles`.
21
+ *
22
+ * Ephemeral / CI access uses the `LOTICS_API_KEY` env var, not a file.
23
+ */
1
24
  export interface LoticsConfig {
2
- api_key?: string;
3
- email?: string;
25
+ profiles?: Record<string, Profile>;
26
+ active_org?: string;
4
27
  workspace_id?: string;
28
+ email?: string;
5
29
  last_update_check?: number;
6
30
  latest_version?: string;
7
31
  }
@@ -9,30 +33,92 @@ export interface LoticsConfig {
9
33
  * Where a config operation reads from or writes to.
10
34
  *
11
35
  * - `"auto"` — walk up from the current working directory; the first ancestor
12
- * containing `.lotics/config.json` wins, else the global `~/.lotics`. This
13
- * lets a project or worktree pin its own account and workspace, and keeps
14
- * resolution stable when commands run from a subdirectory.
36
+ * containing `.lotics/config.json` wins, else the global `~/.lotics`.
15
37
  * - `"local"` — `.lotics/config.json` directly under the current working
16
- * directory. Used to bootstrap a per-directory config (`lotics auth --local`)
17
- * before any local file exists for `"auto"` to discover.
38
+ * directory. Used to pin the current directory exactly (`--local`), with no
39
+ * walk and no inheriting an ancestor's config.
18
40
  */
19
41
  export type ConfigScope = "auto" | "local";
20
42
  export declare function loadConfig(scope?: ConfigScope): LoticsConfig | null;
21
43
  export declare function saveConfig(config: LoticsConfig, scope?: ConfigScope): void;
22
44
  export declare function deleteConfig(scope?: ConfigScope): void;
23
45
  export declare function getConfigPath(scope?: ConfigScope): string;
46
+ /** The global credential store (`~/.lotics/config.json`), regardless of cwd. */
47
+ export declare function loadGlobalConfig(): LoticsConfig | null;
48
+ export declare function saveGlobalConfig(config: LoticsConfig): void;
24
49
  /**
25
- * Check for a newer CLI version. Synchronous prints a warning to stderr
26
- * if the cached latest version is newer than current. Kicks off a background
27
- * fetch if the cache is stale (result is written to config for next run).
50
+ * The local pin governing the current directory, or null if there isn't one.
51
+ * Walks up like `"auto"`, but returns null when the walk lands on the global
52
+ * file so callers can treat "no local pin" distinctly from "global store".
28
53
  */
29
- export declare function checkForUpdate(currentVersion: string): void;
54
+ export declare function loadLocalConfig(): LoticsConfig | null;
30
55
  /**
31
- * Resolve API key from flags, env vars, or config file.
32
- * Priority: flag > env > config file.
56
+ * Find a profile by org id (exact) or org name (case-insensitive). Throws if a
57
+ * name is ambiguous across profiles the caller must use the id. Returns null
58
+ * when nothing matches.
33
59
  */
34
- export declare function resolveAuth(flags: {
35
- apiKey?: string;
36
- }): {
60
+ export declare function resolveProfileByNameOrId(profiles: Record<string, Profile>, nameOrId: string): [string, Profile] | null;
61
+ export type ContextSource = "flag" | "env_key" | "env_org" | "local_pointer" | "global_profile";
62
+ export interface ResolvedContext {
37
63
  apiKey: string;
64
+ workspaceId?: string;
65
+ orgId?: string;
66
+ orgName?: string;
67
+ source: ContextSource;
68
+ }
69
+ /**
70
+ * Resolve the effective `(apiKey, workspaceId)` for a command, plus where they
71
+ * came from. Precedence (highest first):
72
+ *
73
+ * 1. `--api-key` flag
74
+ * 2. `LOTICS_API_KEY` env
75
+ * 3. `LOTICS_ORG` env (name|id → global profile)
76
+ * 4. local `.lotics/config.json` `active_org` pointer (key from global store)
77
+ * 5. global `active_org` profile
78
+ *
79
+ * `--workspace` flag and `LOTICS_WORKSPACE` env override the workspace at every
80
+ * level. Throws (fails loud) when a pin names an org with no saved credential —
81
+ * never silently falls back to a different org.
82
+ */
83
+ export declare function resolveContext(flags: {
84
+ apiKey?: string;
85
+ workspace?: string;
86
+ }): ResolvedContext | null;
87
+ /**
88
+ * Add or update a profile in the global store. Does not change which org is
89
+ * active — the caller decides that via `setActiveOrg`. Preserves the profile's
90
+ * remembered workspace when the caller doesn't supply a fresh one. Writes only
91
+ * the known fields, so any stray top-level keys in an older file are dropped.
92
+ */
93
+ export declare function upsertProfile(orgId: string, fields: {
94
+ api_key: string;
95
+ org_name: string;
96
+ workspace_id?: string;
97
+ }): void;
98
+ /** Remove a profile. If it was active, the active pointer moves to any other. */
99
+ export declare function removeProfile(orgId: string): void;
100
+ /**
101
+ * Set the active org at the requested scope. `"global"` changes the default
102
+ * for every directory that isn't pinned; `"local"` writes a pointer into the
103
+ * current directory's `.lotics/config.json`.
104
+ */
105
+ export declare function setActiveOrg(orgId: string, scope: "global" | "local"): void;
106
+ /**
107
+ * Persist the selected workspace into whichever scope is active: a local pin
108
+ * if the current directory has one, else the active org's global profile.
109
+ * Returns the scope written, or null when there's nowhere persistent to write
110
+ * it (credentials came from `--api-key`/`LOTICS_API_KEY`, with no saved
111
+ * profile) — the caller decides whether that's an error or an in-memory-only
112
+ * selection.
113
+ */
114
+ export declare function setSelectedWorkspace(workspaceId: string): {
115
+ scope: "local" | "global";
116
+ orgId?: string;
38
117
  } | null;
118
+ /**
119
+ * Check for a newer CLI version. Synchronous — prints a warning to stderr
120
+ * if the cached latest version is newer than current. Kicks off a background
121
+ * fetch if the cache is stale (result is written to the global config for next
122
+ * run — the update cadence is a machine-global concern, not per-directory).
123
+ */
124
+ export declare function checkForUpdate(currentVersion: string): void;
package/dist/config.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
+ function globalConfigFile() {
5
+ return path.join(os.homedir(), ".lotics", "config.json");
6
+ }
4
7
  /**
5
8
  * Resolve the config file for a scope.
6
9
  *
@@ -24,25 +27,33 @@ function configFileForScope(scope) {
24
27
  }
25
28
  dir = parent;
26
29
  }
27
- return path.join(os.homedir(), ".lotics", "config.json");
30
+ return globalConfigFile();
28
31
  }
29
- export function loadConfig(scope = "auto") {
32
+ function readConfigFile(file) {
30
33
  try {
31
- const raw = fs.readFileSync(configFileForScope(scope), "utf-8");
34
+ const raw = fs.readFileSync(file, "utf-8");
32
35
  return JSON.parse(raw);
33
36
  }
34
37
  catch {
35
38
  return null;
36
39
  }
37
40
  }
38
- export function saveConfig(config, scope = "auto") {
39
- const file = configFileForScope(scope);
40
- // The config file holds an API key keep it owner-only. `mode` on
41
- // writeFileSync applies only when the file is created, so chmod after to
42
- // also tighten a config written before this protection existed.
41
+ function writeConfigFile(file, config) {
42
+ // The config file holds API keys — keep it owner-only, and write atomically
43
+ // (temp file + rename) so a concurrent reader never sees a half-written store
44
+ // and a crash mid-write can't truncate the existing one. `mode` on
45
+ // writeFileSync only applies on create, so chmod the temp before the rename.
43
46
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
44
- fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
45
- fs.chmodSync(file, 0o600);
47
+ const tmp = `${file}.${process.pid}.tmp`;
48
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
49
+ fs.chmodSync(tmp, 0o600);
50
+ fs.renameSync(tmp, file);
51
+ }
52
+ export function loadConfig(scope = "auto") {
53
+ return readConfigFile(configFileForScope(scope));
54
+ }
55
+ export function saveConfig(config, scope = "auto") {
56
+ writeConfigFile(configFileForScope(scope), config);
46
57
  }
47
58
  export function deleteConfig(scope = "auto") {
48
59
  try {
@@ -55,14 +66,203 @@ export function deleteConfig(scope = "auto") {
55
66
  export function getConfigPath(scope = "auto") {
56
67
  return configFileForScope(scope);
57
68
  }
69
+ /** The global credential store (`~/.lotics/config.json`), regardless of cwd. */
70
+ export function loadGlobalConfig() {
71
+ return readConfigFile(globalConfigFile());
72
+ }
73
+ export function saveGlobalConfig(config) {
74
+ writeConfigFile(globalConfigFile(), config);
75
+ }
76
+ /**
77
+ * The local pin governing the current directory, or null if there isn't one.
78
+ * Walks up like `"auto"`, but returns null when the walk lands on the global
79
+ * file — so callers can treat "no local pin" distinctly from "global store".
80
+ */
81
+ export function loadLocalConfig() {
82
+ const file = configFileForScope("auto");
83
+ if (file === globalConfigFile())
84
+ return null;
85
+ return readConfigFile(file);
86
+ }
87
+ /**
88
+ * Find a profile by org id (exact) or org name (case-insensitive). Throws if a
89
+ * name is ambiguous across profiles — the caller must use the id. Returns null
90
+ * when nothing matches.
91
+ */
92
+ export function resolveProfileByNameOrId(profiles, nameOrId) {
93
+ const byId = profiles[nameOrId];
94
+ if (byId)
95
+ return [nameOrId, byId];
96
+ const matches = Object.entries(profiles).filter(([, p]) => p.org_name.toLowerCase() === nameOrId.toLowerCase());
97
+ if (matches.length === 1)
98
+ return matches[0];
99
+ if (matches.length > 1) {
100
+ throw new Error(`"${nameOrId}" matches multiple saved orgs: ${matches.map(([id]) => id).join(", ")}. Use the org id.`);
101
+ }
102
+ return null;
103
+ }
104
+ /**
105
+ * Resolve the effective `(apiKey, workspaceId)` for a command, plus where they
106
+ * came from. Precedence (highest first):
107
+ *
108
+ * 1. `--api-key` flag
109
+ * 2. `LOTICS_API_KEY` env
110
+ * 3. `LOTICS_ORG` env (name|id → global profile)
111
+ * 4. local `.lotics/config.json` `active_org` pointer (key from global store)
112
+ * 5. global `active_org` profile
113
+ *
114
+ * `--workspace` flag and `LOTICS_WORKSPACE` env override the workspace at every
115
+ * level. Throws (fails loud) when a pin names an org with no saved credential —
116
+ * never silently falls back to a different org.
117
+ */
118
+ export function resolveContext(flags) {
119
+ const envKey = process.env.LOTICS_API_KEY;
120
+ const envOrg = process.env.LOTICS_ORG;
121
+ const envWorkspace = process.env.LOTICS_WORKSPACE;
122
+ const wsOverride = flags.workspace ?? envWorkspace;
123
+ if (flags.apiKey) {
124
+ return { apiKey: flags.apiKey, workspaceId: wsOverride, source: "flag" };
125
+ }
126
+ if (envKey) {
127
+ return { apiKey: envKey, workspaceId: wsOverride, source: "env_key" };
128
+ }
129
+ const global = loadGlobalConfig();
130
+ const profiles = global?.profiles ?? {};
131
+ if (envOrg) {
132
+ const resolved = resolveProfileByNameOrId(profiles, envOrg);
133
+ if (!resolved) {
134
+ throw new Error(`LOTICS_ORG="${envOrg}" matches no saved credential. Run "lotics org" to list, or "lotics auth api-key <key>" to add one.`);
135
+ }
136
+ const [orgId, profile] = resolved;
137
+ return {
138
+ apiKey: profile.api_key,
139
+ orgId,
140
+ orgName: profile.org_name,
141
+ workspaceId: wsOverride ?? profile.workspace_id,
142
+ source: "env_org",
143
+ };
144
+ }
145
+ const local = loadLocalConfig();
146
+ if (local?.active_org) {
147
+ const profile = profiles[local.active_org];
148
+ if (!profile) {
149
+ throw new Error(`This directory is pinned to org "${local.active_org}" (.lotics/config.json) but no saved credential exists for it. Run "lotics auth api-key <key>" while authenticated to that org.`);
150
+ }
151
+ return {
152
+ apiKey: profile.api_key,
153
+ orgId: local.active_org,
154
+ orgName: profile.org_name,
155
+ workspaceId: wsOverride ?? local.workspace_id ?? profile.workspace_id,
156
+ source: "local_pointer",
157
+ };
158
+ }
159
+ // A local file carrying an inline key is the obsolete self-contained format —
160
+ // reject it loudly rather than silently falling through to the global active
161
+ // org (which would run this directory against the wrong organization).
162
+ if (local && "api_key" in local) {
163
+ throw new Error(`This directory's .lotics/config.json uses the old self-contained format (inline key), which is no longer supported. Re-pin with "lotics auth api-key <key> --local" (or "lotics org use <name|id> --local").`);
164
+ }
165
+ if (global?.active_org) {
166
+ const profile = profiles[global.active_org];
167
+ if (!profile) {
168
+ throw new Error(`Active org "${global.active_org}" has no saved credential. Run "lotics org use <name|id>" to pick one, or "lotics auth api-key <key>".`);
169
+ }
170
+ return {
171
+ apiKey: profile.api_key,
172
+ orgId: global.active_org,
173
+ orgName: profile.org_name,
174
+ workspaceId: wsOverride ?? profile.workspace_id,
175
+ source: "global_profile",
176
+ };
177
+ }
178
+ return null;
179
+ }
180
+ // --- Credential store mutators (always target the global store) ------------
181
+ /**
182
+ * Add or update a profile in the global store. Does not change which org is
183
+ * active — the caller decides that via `setActiveOrg`. Preserves the profile's
184
+ * remembered workspace when the caller doesn't supply a fresh one. Writes only
185
+ * the known fields, so any stray top-level keys in an older file are dropped.
186
+ */
187
+ export function upsertProfile(orgId, fields) {
188
+ const config = loadGlobalConfig() ?? {};
189
+ const existing = config.profiles?.[orgId];
190
+ const profile = {
191
+ api_key: fields.api_key,
192
+ org_name: fields.org_name,
193
+ workspace_id: fields.workspace_id ?? existing?.workspace_id,
194
+ };
195
+ saveGlobalConfig({
196
+ profiles: { ...config.profiles, [orgId]: profile },
197
+ active_org: config.active_org,
198
+ email: config.email,
199
+ last_update_check: config.last_update_check,
200
+ latest_version: config.latest_version,
201
+ });
202
+ }
203
+ /** Remove a profile. If it was active, the active pointer moves to any other. */
204
+ export function removeProfile(orgId) {
205
+ const config = loadGlobalConfig();
206
+ if (!config?.profiles?.[orgId])
207
+ return;
208
+ const { [orgId]: _removed, ...rest } = config.profiles;
209
+ const remaining = Object.keys(rest);
210
+ saveGlobalConfig({
211
+ ...config,
212
+ profiles: rest,
213
+ active_org: config.active_org === orgId ? remaining[0] : config.active_org,
214
+ });
215
+ }
216
+ /**
217
+ * Set the active org at the requested scope. `"global"` changes the default
218
+ * for every directory that isn't pinned; `"local"` writes a pointer into the
219
+ * current directory's `.lotics/config.json`.
220
+ */
221
+ export function setActiveOrg(orgId, scope) {
222
+ if (scope === "local") {
223
+ // Write a clean pointer — dropping any stale fields from an older local
224
+ // file. Workspace resets to the org's remembered default (the profile's).
225
+ saveConfig({ active_org: orgId }, "local");
226
+ return;
227
+ }
228
+ const config = loadGlobalConfig() ?? {};
229
+ saveGlobalConfig({ ...config, active_org: orgId });
230
+ }
231
+ /**
232
+ * Persist the selected workspace into whichever scope is active: a local pin
233
+ * if the current directory has one, else the active org's global profile.
234
+ * Returns the scope written, or null when there's nowhere persistent to write
235
+ * it (credentials came from `--api-key`/`LOTICS_API_KEY`, with no saved
236
+ * profile) — the caller decides whether that's an error or an in-memory-only
237
+ * selection.
238
+ */
239
+ export function setSelectedWorkspace(workspaceId) {
240
+ const local = loadLocalConfig();
241
+ if (local?.active_org) {
242
+ saveConfig({ ...local, workspace_id: workspaceId }, "auto");
243
+ return { scope: "local", orgId: local.active_org };
244
+ }
245
+ const config = loadGlobalConfig() ?? {};
246
+ const orgId = config.active_org;
247
+ const profile = orgId ? config.profiles?.[orgId] : undefined;
248
+ if (!orgId || !profile) {
249
+ return null;
250
+ }
251
+ saveGlobalConfig({
252
+ ...config,
253
+ profiles: { ...config.profiles, [orgId]: { ...profile, workspace_id: workspaceId } },
254
+ });
255
+ return { scope: "global", orgId };
256
+ }
58
257
  const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
59
258
  /**
60
259
  * Check for a newer CLI version. Synchronous — prints a warning to stderr
61
260
  * if the cached latest version is newer than current. Kicks off a background
62
- * fetch if the cache is stale (result is written to config for next run).
261
+ * fetch if the cache is stale (result is written to the global config for next
262
+ * run — the update cadence is a machine-global concern, not per-directory).
63
263
  */
64
264
  export function checkForUpdate(currentVersion) {
65
- const config = loadConfig();
265
+ const config = loadGlobalConfig();
66
266
  const lastCheck = config?.last_update_check ?? 0;
67
267
  if (Date.now() - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
68
268
  if (config?.latest_version && config.latest_version !== currentVersion) {
@@ -74,8 +274,8 @@ export function checkForUpdate(currentVersion) {
74
274
  fetchLatestVersion().then((latest) => {
75
275
  if (!latest)
76
276
  return;
77
- const existing = loadConfig() ?? {};
78
- saveConfig({ ...existing, last_update_check: Date.now(), latest_version: latest });
277
+ const existing = loadGlobalConfig() ?? {};
278
+ saveGlobalConfig({ ...existing, last_update_check: Date.now(), latest_version: latest });
79
279
  }).catch(() => { });
80
280
  }
81
281
  async function fetchLatestVersion() {
@@ -113,14 +313,3 @@ function printUpdateWarning(current, latest) {
113
313
  console.error(`\nUpdate available: ${current} → ${latest}`);
114
314
  console.error(`Run: npm i -g @lotics/cli\n`);
115
315
  }
116
- /**
117
- * Resolve API key from flags, env vars, or config file.
118
- * Priority: flag > env > config file.
119
- */
120
- export function resolveAuth(flags) {
121
- const config = loadConfig();
122
- const apiKey = flags.apiKey ?? process.env.LOTICS_API_KEY ?? config?.api_key;
123
- if (!apiKey)
124
- return null;
125
- return { apiKey };
126
- }