@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.
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
- import { loadConfig, saveConfig, deleteConfig, getConfigPath } from "./config.js";
5
+ import { loadConfig, saveConfig, deleteConfig, getConfigPath, loadGlobalConfig, resolveContext, resolveProfileByNameOrId, upsertProfile, removeProfile, setActiveOrg, setSelectedWorkspace, } from "./config.js";
6
6
  /**
7
7
  * Config resolution walks up from the current working directory: the first
8
8
  * ancestor with a `.lotics/config.json` wins, otherwise the global
@@ -50,63 +50,270 @@ describe("config file resolution", () => {
50
50
  expect(loadConfig()).toBeNull();
51
51
  });
52
52
  it("reads the global config when no local config exists", () => {
53
- write(globalFile(), { api_key: "ltk_global" });
54
- expect(loadConfig()?.api_key).toBe("ltk_global");
53
+ write(globalFile(), { active_org: "org_global" });
54
+ expect(loadConfig()?.active_org).toBe("org_global");
55
55
  expect(getConfigPath()).toBe(globalFile());
56
56
  });
57
57
  it("prefers a .lotics/config.json in the current directory over the global one", () => {
58
- write(globalFile(), { api_key: "ltk_global" });
59
- write(localFile(), { api_key: "ltk_local" });
60
- expect(loadConfig()?.api_key).toBe("ltk_local");
58
+ write(globalFile(), { active_org: "org_global" });
59
+ write(localFile(), { active_org: "org_local" });
60
+ expect(loadConfig()?.active_org).toBe("org_local");
61
61
  expect(getConfigPath()).toBe(localFile());
62
62
  });
63
63
  it("walks up to an ancestor's config when run from a subdirectory", () => {
64
- write(localFile(), { api_key: "ltk_project_root" });
64
+ write(localFile(), { active_org: "org_project_root" });
65
65
  chdirInto("backend", "features");
66
- expect(loadConfig()?.api_key).toBe("ltk_project_root");
66
+ expect(loadConfig()?.active_org).toBe("org_project_root");
67
67
  expect(getConfigPath()).toBe(localFile());
68
68
  });
69
69
  it("auto saveConfig writes the resolved ancestor file, not the subdirectory", () => {
70
- write(localFile(), { api_key: "ltk_old" });
70
+ write(localFile(), { active_org: "org_old" });
71
71
  const nested = chdirInto("sub");
72
- saveConfig({ api_key: "ltk_new" });
73
- expect(read(localFile()).api_key).toBe("ltk_new");
72
+ saveConfig({ active_org: "org_new" });
73
+ expect(read(localFile()).active_org).toBe("org_new");
74
74
  expect(fs.existsSync(localFile(nested))).toBe(false);
75
75
  });
76
76
  it("auto saveConfig writes the global config when no local config exists", () => {
77
- saveConfig({ api_key: "ltk_new" });
77
+ saveConfig({ active_org: "org_new" });
78
78
  expect(fs.existsSync(localFile())).toBe(false);
79
- expect(read(globalFile()).api_key).toBe("ltk_new");
79
+ expect(read(globalFile()).active_org).toBe("org_new");
80
80
  });
81
81
  it("scope 'local' creates ./.lotics/config.json even when none existed", () => {
82
- saveConfig({ api_key: "ltk_pinned" }, "local");
83
- expect(read(localFile()).api_key).toBe("ltk_pinned");
82
+ saveConfig({ active_org: "org_pinned" }, "local");
83
+ expect(read(localFile()).active_org).toBe("org_pinned");
84
84
  expect(getConfigPath("local")).toBe(localFile());
85
85
  });
86
86
  it("saveConfig writes the credentials file owner-only (0600)", () => {
87
- saveConfig({ api_key: "ltk_secret" }, "local");
87
+ saveConfig({ active_org: "org_secret" }, "local");
88
88
  expect(fs.statSync(localFile()).mode & 0o777).toBe(0o600);
89
89
  });
90
90
  it("saveConfig tightens an over-permissive pre-existing config file", () => {
91
- write(localFile(), { api_key: "ltk_old" });
91
+ write(localFile(), { active_org: "org_old" });
92
92
  fs.chmodSync(localFile(), 0o644);
93
- saveConfig({ api_key: "ltk_new" }, "local");
93
+ saveConfig({ active_org: "org_new" }, "local");
94
94
  expect(fs.statSync(localFile()).mode & 0o777).toBe(0o600);
95
95
  });
96
96
  it("loadConfig('local') never inherits the global config", () => {
97
- write(globalFile(), { api_key: "ltk_global" });
97
+ write(globalFile(), { active_org: "org_global" });
98
98
  expect(loadConfig("local")).toBeNull();
99
99
  });
100
100
  it("loadConfig('local') ignores an ancestor's config — it reads the exact cwd only", () => {
101
- write(localFile(), { api_key: "ltk_root" });
101
+ write(localFile(), { active_org: "org_root" });
102
102
  chdirInto("sub");
103
103
  expect(loadConfig("local")).toBeNull();
104
104
  });
105
105
  it("deleteConfig removes the active local config, leaving the global one untouched", () => {
106
- write(globalFile(), { api_key: "ltk_global" });
107
- write(localFile(), { api_key: "ltk_local" });
106
+ write(globalFile(), { active_org: "org_global" });
107
+ write(localFile(), { active_org: "org_local" });
108
108
  deleteConfig();
109
109
  expect(fs.existsSync(localFile())).toBe(false);
110
110
  expect(fs.existsSync(globalFile())).toBe(true);
111
111
  });
112
112
  });
113
+ /**
114
+ * The credential store holds many profiles (one per org) in the global config,
115
+ * while a local pin or env var picks which is active. `resolveContext` is the
116
+ * single chain that turns flags + env + files into the effective
117
+ * (apiKey, workspaceId) — these tests pin home/cwd to temp dirs and scrub the
118
+ * LOTICS_* env vars so the real environment is never read.
119
+ */
120
+ describe("profile store + resolveContext", () => {
121
+ let homeDir;
122
+ let projectDir;
123
+ let originalCwd;
124
+ const ENV_KEYS = ["LOTICS_API_KEY", "LOTICS_ORG", "LOTICS_WORKSPACE"];
125
+ let savedEnv;
126
+ beforeEach(() => {
127
+ originalCwd = process.cwd();
128
+ homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "lotics-home-"));
129
+ projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "lotics-project-"));
130
+ vi.spyOn(os, "homedir").mockReturnValue(homeDir);
131
+ process.chdir(projectDir);
132
+ savedEnv = {};
133
+ for (const k of ENV_KEYS) {
134
+ savedEnv[k] = process.env[k];
135
+ delete process.env[k];
136
+ }
137
+ });
138
+ afterEach(() => {
139
+ process.chdir(originalCwd);
140
+ vi.restoreAllMocks();
141
+ for (const k of ENV_KEYS) {
142
+ if (savedEnv[k] === undefined)
143
+ delete process.env[k];
144
+ else
145
+ process.env[k] = savedEnv[k];
146
+ }
147
+ fs.rmSync(homeDir, { recursive: true, force: true });
148
+ fs.rmSync(projectDir, { recursive: true, force: true });
149
+ });
150
+ // Raw writers: accept any JSON shape so tests can simulate older on-disk
151
+ // formats (e.g. a pre-profiles inline key) the current type no longer models.
152
+ const globalConfigFile = () => path.join(homeDir, ".lotics", "config.json");
153
+ const writeGlobalRaw = (data) => {
154
+ fs.mkdirSync(path.dirname(globalConfigFile()), { recursive: true });
155
+ fs.writeFileSync(globalConfigFile(), JSON.stringify(data));
156
+ };
157
+ const writeLocalRaw = (data) => {
158
+ const file = path.join(projectDir, ".lotics", "config.json");
159
+ fs.mkdirSync(path.dirname(file), { recursive: true });
160
+ fs.writeFileSync(file, JSON.stringify(data));
161
+ };
162
+ const readGlobalRaw = () => JSON.parse(fs.readFileSync(globalConfigFile(), "utf-8"));
163
+ const profile = (orgName, key, ws) => ({
164
+ api_key: key,
165
+ org_name: orgName,
166
+ ...(ws ? { workspace_id: ws } : {}),
167
+ });
168
+ // --- resolveProfileByNameOrId ---
169
+ it("resolves a profile by exact org id", () => {
170
+ const profiles = { org_a: profile("Acme", "ltk_a") };
171
+ expect(resolveProfileByNameOrId(profiles, "org_a")?.[0]).toBe("org_a");
172
+ });
173
+ it("resolves a profile by case-insensitive org name", () => {
174
+ const profiles = { org_a: profile("Acme", "ltk_a") };
175
+ expect(resolveProfileByNameOrId(profiles, "acme")?.[0]).toBe("org_a");
176
+ });
177
+ it("returns null when no profile matches", () => {
178
+ expect(resolveProfileByNameOrId({ org_a: profile("Acme", "ltk_a") }, "nope")).toBeNull();
179
+ });
180
+ it("throws when an org name is ambiguous", () => {
181
+ const profiles = { org_a: profile("Dup", "ltk_a"), org_b: profile("Dup", "ltk_b") };
182
+ expect(() => resolveProfileByNameOrId(profiles, "Dup")).toThrow(/multiple/i);
183
+ });
184
+ // --- precedence chain ---
185
+ it("uses the --api-key flag above everything", () => {
186
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a") }, active_org: "org_a" });
187
+ const ctx = resolveContext({ apiKey: "ltk_flag", workspace: "wsp_flag" });
188
+ expect(ctx).toMatchObject({ apiKey: "ltk_flag", workspaceId: "wsp_flag", source: "flag" });
189
+ });
190
+ it("uses LOTICS_API_KEY above LOTICS_ORG and the files", () => {
191
+ process.env.LOTICS_API_KEY = "ltk_env";
192
+ process.env.LOTICS_WORKSPACE = "wsp_env";
193
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a") }, active_org: "org_a" });
194
+ const ctx = resolveContext({});
195
+ expect(ctx).toMatchObject({ apiKey: "ltk_env", workspaceId: "wsp_env", source: "env_key" });
196
+ });
197
+ it("resolves LOTICS_ORG (by name) against the global store", () => {
198
+ process.env.LOTICS_ORG = "Acme";
199
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a", "wsp_a") }, active_org: "org_a" });
200
+ const ctx = resolveContext({});
201
+ expect(ctx).toMatchObject({ apiKey: "ltk_a", orgId: "org_a", workspaceId: "wsp_a", source: "env_org" });
202
+ });
203
+ it("throws when LOTICS_ORG names no saved credential", () => {
204
+ process.env.LOTICS_ORG = "ghost";
205
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a") }, active_org: "org_a" });
206
+ expect(() => resolveContext({})).toThrow(/LOTICS_ORG/);
207
+ });
208
+ it("rejects a local config in the obsolete self-contained format (inline key)", () => {
209
+ // Must fail loud rather than silently fall through to the global active org,
210
+ // which would run this directory against the wrong organization.
211
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a") }, active_org: "org_a" });
212
+ writeLocalRaw({ api_key: "ltk_local", workspace_id: "wsp_local" });
213
+ expect(() => resolveContext({})).toThrow(/self-contained format/i);
214
+ });
215
+ it("resolves a local pointer's key from the global store", () => {
216
+ writeGlobalRaw({ profiles: { org_b: profile("Beta", "ltk_b", "wsp_b_default") }, active_org: "org_a" });
217
+ writeLocalRaw({ active_org: "org_b", workspace_id: "wsp_b_pinned" });
218
+ const ctx = resolveContext({});
219
+ expect(ctx).toMatchObject({
220
+ apiKey: "ltk_b",
221
+ orgId: "org_b",
222
+ workspaceId: "wsp_b_pinned",
223
+ source: "local_pointer",
224
+ });
225
+ });
226
+ it("fails loud when a local pin names an org with no saved credential", () => {
227
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a") }, active_org: "org_a" });
228
+ writeLocalRaw({ active_org: "org_missing" });
229
+ expect(() => resolveContext({})).toThrow(/no saved credential/i);
230
+ });
231
+ it("falls back to the global active profile when no override exists", () => {
232
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a", "wsp_a") }, active_org: "org_a" });
233
+ const ctx = resolveContext({});
234
+ expect(ctx).toMatchObject({ apiKey: "ltk_a", orgId: "org_a", workspaceId: "wsp_a", source: "global_profile" });
235
+ });
236
+ it("fails loud when the global active_org has no matching profile", () => {
237
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a") }, active_org: "org_gone" });
238
+ expect(() => resolveContext({})).toThrow(/no saved credential/i);
239
+ });
240
+ it("returns null for an old flat global config (no profiles) — re-auth required", () => {
241
+ writeGlobalRaw({ api_key: "ltk_legacy", workspace_id: "wsp_legacy" });
242
+ expect(resolveContext({})).toBeNull();
243
+ });
244
+ it("returns null when no credentials exist anywhere", () => {
245
+ expect(resolveContext({})).toBeNull();
246
+ });
247
+ it("lets --workspace / LOTICS_WORKSPACE override the resolved workspace", () => {
248
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a", "wsp_default") }, active_org: "org_a" });
249
+ expect(resolveContext({ workspace: "wsp_override" })?.workspaceId).toBe("wsp_override");
250
+ process.env.LOTICS_WORKSPACE = "wsp_env";
251
+ expect(resolveContext({})?.workspaceId).toBe("wsp_env");
252
+ });
253
+ // --- mutators ---
254
+ it("upsertProfile adds a profile and preserves the active pointer + the rest", () => {
255
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a") }, active_org: "org_a", email: "me@x.io" });
256
+ upsertProfile("org_b", { api_key: "ltk_b", org_name: "Beta", workspace_id: "wsp_b" });
257
+ const g = loadGlobalConfig();
258
+ expect(Object.keys(g.profiles)).toEqual(["org_a", "org_b"]);
259
+ expect(g.active_org).toBe("org_a"); // unchanged — the command sets active, not upsert
260
+ expect(g.email).toBe("me@x.io");
261
+ });
262
+ it("upsertProfile self-heals an old flat-key file by dropping stray top-level fields", () => {
263
+ writeGlobalRaw({ api_key: "ltk_legacy", workspace_id: "wsp_legacy", email: "me@x.io" });
264
+ upsertProfile("org_a", { api_key: "ltk_a", org_name: "Acme" });
265
+ const raw = readGlobalRaw();
266
+ expect(raw.api_key).toBeUndefined();
267
+ expect(raw.workspace_id).toBeUndefined();
268
+ expect(raw.email).toBe("me@x.io");
269
+ expect(raw.profiles.org_a.api_key).toBe("ltk_a");
270
+ });
271
+ it("upsertProfile keeps the remembered workspace when none is supplied", () => {
272
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a", "wsp_a") }, active_org: "org_a" });
273
+ upsertProfile("org_a", { api_key: "ltk_a2", org_name: "Acme" });
274
+ expect(loadGlobalConfig().profiles.org_a.workspace_id).toBe("wsp_a");
275
+ });
276
+ it("removeProfile deletes a profile and moves the active pointer", () => {
277
+ writeGlobalRaw({
278
+ profiles: { org_a: profile("Acme", "ltk_a"), org_b: profile("Beta", "ltk_b") },
279
+ active_org: "org_a",
280
+ });
281
+ removeProfile("org_a");
282
+ const g = loadGlobalConfig();
283
+ expect(g.profiles.org_a).toBeUndefined();
284
+ expect(g.active_org).toBe("org_b");
285
+ });
286
+ it("setActiveOrg('global') changes only the global default", () => {
287
+ writeGlobalRaw({
288
+ profiles: { org_a: profile("Acme", "ltk_a"), org_b: profile("Beta", "ltk_b") },
289
+ active_org: "org_a",
290
+ });
291
+ setActiveOrg("org_b", "global");
292
+ expect(loadGlobalConfig().active_org).toBe("org_b");
293
+ expect(fs.existsSync(path.join(projectDir, ".lotics", "config.json"))).toBe(false);
294
+ });
295
+ it("setActiveOrg('local') writes a pointer into the current directory only", () => {
296
+ writeGlobalRaw({ profiles: { org_b: profile("Beta", "ltk_b") }, active_org: "org_a" });
297
+ setActiveOrg("org_b", "local");
298
+ expect(loadConfig("local").active_org).toBe("org_b");
299
+ expect(loadGlobalConfig().active_org).toBe("org_a"); // global untouched
300
+ });
301
+ it("setSelectedWorkspace writes to the active global profile", () => {
302
+ writeGlobalRaw({ profiles: { org_a: profile("Acme", "ltk_a") }, active_org: "org_a" });
303
+ const result = setSelectedWorkspace("wsp_new");
304
+ expect(result).toEqual({ scope: "global", orgId: "org_a" });
305
+ expect(loadGlobalConfig().profiles.org_a.workspace_id).toBe("wsp_new");
306
+ });
307
+ it("setSelectedWorkspace writes to a local pin when present", () => {
308
+ writeGlobalRaw({ profiles: { org_b: profile("Beta", "ltk_b") }, active_org: "org_a" });
309
+ writeLocalRaw({ active_org: "org_b" });
310
+ const result = setSelectedWorkspace("wsp_pinned");
311
+ expect(result).toMatchObject({ scope: "local", orgId: "org_b" });
312
+ expect(loadConfig("local").workspace_id).toBe("wsp_pinned");
313
+ expect(loadGlobalConfig().profiles.org_b.workspace_id).toBeUndefined();
314
+ });
315
+ it("setSelectedWorkspace returns null when there is nowhere to persist it", () => {
316
+ // No local pin, no active global profile (e.g. creds came from --api-key).
317
+ expect(setSelectedWorkspace("wsp_x")).toBeNull();
318
+ });
319
+ });
@@ -145,6 +145,20 @@ export function buildWrapperPage(args) {
145
145
  return completed.file;
146
146
  }
147
147
 
148
+ // openExternal — the wrapper page is the top frame (un-sandboxed), so it
149
+ // opens the link directly; NOT forwarded to the RPC endpoint (no server
150
+ // op). Re-validate the scheme; never trust the iframe's payload.
151
+ function handleOpenExternal(payload) {
152
+ var url = payload && payload.url;
153
+ if (typeof url !== "string") throw new Error("openExternal requires a url string");
154
+ var parsed = new URL(url);
155
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
156
+ throw new Error('openExternal: unsupported URL scheme "' + parsed.protocol + '"');
157
+ }
158
+ window.open(parsed.href, "_blank", "noopener,noreferrer");
159
+ return undefined;
160
+ }
161
+
148
162
  window.addEventListener("message", async function (event) {
149
163
  if (event.source !== iframe.contentWindow || event.origin !== VITE_ORIGIN) return;
150
164
  const msg = event.data;
@@ -153,6 +167,8 @@ export function buildWrapperPage(args) {
153
167
  try {
154
168
  const data = msg.op === "upload"
155
169
  ? await handleUpload(msg.payload)
170
+ : msg.op === "openExternal"
171
+ ? handleOpenExternal(msg.payload)
156
172
  : await rpc(msg.op, msg.payload);
157
173
  const ms = Math.round(performance.now() - startedAt);
158
174
  console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);