@lotics/cli 0.24.0 → 0.25.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 +16 -2
- package/dist/src/cli.js +36 -12
- package/dist/src/config.d.ts +16 -4
- package/dist/src/config.js +39 -11
- package/dist/src/config.test.d.ts +1 -0
- package/dist/src/config.test.js +112 -0
- package/dist/src/dev/wrapper_page.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,9 +46,23 @@ lotics auth api-key # interactive prompt
|
|
|
46
46
|
lotics auth api-key ltk_... # non-interactive
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
API key is saved to
|
|
49
|
+
API key is saved to the config file. Run `lotics auth logout` to remove saved credentials.
|
|
50
50
|
|
|
51
|
-
Auth priority: `--api-key` flag > `LOTICS_API_KEY` env >
|
|
51
|
+
Auth priority: `--api-key` flag > `LOTICS_API_KEY` env > config file.
|
|
52
|
+
|
|
53
|
+
### Config file location
|
|
54
|
+
|
|
55
|
+
The CLI resolves `.lotics/config.json` by walking up from the current working directory — the first ancestor that has one wins, otherwise the global `~/.lotics/config.json`. A per-directory config lets a project or worktree pin its own account and workspace; commands run from a subdirectory still resolve to it.
|
|
56
|
+
|
|
57
|
+
Create one by passing `--local` to `lotics auth`:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
cd my-worktree
|
|
61
|
+
lotics auth api-key ltk_... --local # writes ./.lotics/config.json
|
|
62
|
+
lotics workspace select wks_... # auto-resolves to the local config
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`.lotics/` should be gitignored. Note: an exported `LOTICS_API_KEY` overrides the config file's key.
|
|
52
66
|
|
|
53
67
|
## Workspaces
|
|
54
68
|
|
package/dist/src/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import readline from "node:readline";
|
|
5
5
|
import { LoticsClient, API_BASE_URL } from "./client.js";
|
|
6
|
-
import { resolveAuth, loadConfig, saveConfig, deleteConfig, checkForUpdate } from "./config.js";
|
|
6
|
+
import { resolveAuth, loadConfig, saveConfig, deleteConfig, getConfigPath, checkForUpdate } from "./config.js";
|
|
7
7
|
import { VERSION } from "./version.js";
|
|
8
8
|
import { appCreate, appPull, appDeploy, appDev } from "./app_commands.js";
|
|
9
9
|
function printHelp() {
|
|
@@ -58,8 +58,15 @@ FLAGS
|
|
|
58
58
|
-o <path> Output dir for downloads
|
|
59
59
|
--as <name> Override upload filename
|
|
60
60
|
--api-key <key> API key (overrides saved config and LOTICS_API_KEY)
|
|
61
|
+
--local (lotics auth) Save credentials to ./.lotics/config.json
|
|
61
62
|
--version Show version
|
|
62
63
|
|
|
64
|
+
CONFIG
|
|
65
|
+
Credentials resolve from a .lotics/config.json found by walking up from the
|
|
66
|
+
current directory, else the global ~/.lotics/config.json. Run "lotics auth
|
|
67
|
+
api-key <key> --local" inside a directory (e.g. a worktree) to pin it to its
|
|
68
|
+
own account and workspace.
|
|
69
|
+
|
|
63
70
|
OUTPUT
|
|
64
71
|
Default output is a compact text summary optimized for AI agents —
|
|
65
72
|
use it directly, no parsing needed. --json returns raw structured
|
|
@@ -92,13 +99,20 @@ function printAuthHelp() {
|
|
|
92
99
|
lotics auth whoami Show the current account's name, email, and organization
|
|
93
100
|
lotics auth logout Remove saved credentials
|
|
94
101
|
|
|
95
|
-
|
|
96
|
-
--
|
|
97
|
-
|
|
102
|
+
Auth flags:
|
|
103
|
+
--local Save credentials to ./.lotics/config.json (this directory)
|
|
104
|
+
instead of the global ~/.lotics — applies to signup + api-key
|
|
105
|
+
--name <name> (signup) Display name (defaults to email prefix)
|
|
106
|
+
--timezone <timezone> (signup) Workspace timezone (defaults to UTC, e.g. Asia/Ho_Chi_Minh)
|
|
98
107
|
|
|
99
108
|
Signup sends a magic link email so you can access the Lotics web app.
|
|
100
109
|
Use lotics auth web to request a new magic link at any time.
|
|
101
|
-
|
|
110
|
+
|
|
111
|
+
Auth priority: --api-key flag > LOTICS_API_KEY env > config file.
|
|
112
|
+
Config file: .lotics/config.json found by walking up from the current directory,
|
|
113
|
+
else ~/.lotics/config.json. A per-directory config pins a project or worktree to
|
|
114
|
+
its own account and workspace; --local creates one. Note: an exported
|
|
115
|
+
LOTICS_API_KEY env var overrides the config file's key.`);
|
|
102
116
|
}
|
|
103
117
|
function parseArgs(argv) {
|
|
104
118
|
const flags = {
|
|
@@ -109,6 +123,7 @@ function parseArgs(argv) {
|
|
|
109
123
|
apiKey: undefined,
|
|
110
124
|
name: undefined,
|
|
111
125
|
timezone: undefined,
|
|
126
|
+
local: false,
|
|
112
127
|
version: false,
|
|
113
128
|
help: false,
|
|
114
129
|
};
|
|
@@ -142,6 +157,9 @@ function parseArgs(argv) {
|
|
|
142
157
|
case "--timezone":
|
|
143
158
|
flags.timezone = argv[++i];
|
|
144
159
|
break;
|
|
160
|
+
case "--local":
|
|
161
|
+
flags.local = true;
|
|
162
|
+
break;
|
|
145
163
|
case "--version":
|
|
146
164
|
case "-v":
|
|
147
165
|
flags.version = true;
|
|
@@ -223,19 +241,21 @@ async function handleSignup(positionalEmail, flags) {
|
|
|
223
241
|
}
|
|
224
242
|
process.exit(1);
|
|
225
243
|
}
|
|
226
|
-
const
|
|
244
|
+
const scope = flags.local ? "local" : "auto";
|
|
245
|
+
const existing = loadConfig(scope) ?? {};
|
|
227
246
|
saveConfig({
|
|
228
247
|
...existing,
|
|
229
248
|
api_key: data.api_key,
|
|
230
249
|
email: data.email,
|
|
231
250
|
workspace_id: data.workspace_id,
|
|
232
|
-
});
|
|
251
|
+
}, scope);
|
|
233
252
|
console.error(`Account created. You can now use the CLI.`);
|
|
234
|
-
console.error(` Email:
|
|
253
|
+
console.error(` Email: ${data.email}`);
|
|
254
|
+
console.error(` Config: ${getConfigPath(scope)}`);
|
|
235
255
|
console.error(`\nCheck your email for a magic link to access the Lotics web app.`);
|
|
236
256
|
console.error(`Run "lotics auth web" to request a new link at any time.`);
|
|
237
257
|
}
|
|
238
|
-
async function handleSetup(providedKey) {
|
|
258
|
+
async function handleSetup(providedKey, local) {
|
|
239
259
|
const apiKey = providedKey ?? await prompt("Enter your API key: ");
|
|
240
260
|
if (!apiKey) {
|
|
241
261
|
console.error("No API key provided.");
|
|
@@ -252,7 +272,8 @@ async function handleSetup(providedKey) {
|
|
|
252
272
|
console.error(`Authentication failed: ${message}`);
|
|
253
273
|
process.exit(1);
|
|
254
274
|
}
|
|
255
|
-
const
|
|
275
|
+
const scope = local ? "local" : "auto";
|
|
276
|
+
const existing = loadConfig(scope) ?? {};
|
|
256
277
|
const newConfig = { ...existing, api_key: apiKey, email };
|
|
257
278
|
// Auto-resolve workspace
|
|
258
279
|
try {
|
|
@@ -269,8 +290,9 @@ async function handleSetup(providedKey) {
|
|
|
269
290
|
const msg = error instanceof Error ? error.message : String(error);
|
|
270
291
|
console.error(`Warning: could not resolve workspace: ${msg}`);
|
|
271
292
|
}
|
|
272
|
-
saveConfig(newConfig);
|
|
293
|
+
saveConfig(newConfig, scope);
|
|
273
294
|
console.error("Authenticated.");
|
|
295
|
+
console.error(` Config: ${getConfigPath(scope)}`);
|
|
274
296
|
}
|
|
275
297
|
function requireClient(flags) {
|
|
276
298
|
const auth = resolveAuth(flags);
|
|
@@ -356,7 +378,7 @@ async function main() {
|
|
|
356
378
|
return;
|
|
357
379
|
}
|
|
358
380
|
if (subcommand === "api-key") {
|
|
359
|
-
await handleSetup(toolArgs ?? flags.apiKey);
|
|
381
|
+
await handleSetup(toolArgs ?? flags.apiKey, flags.local);
|
|
360
382
|
return;
|
|
361
383
|
}
|
|
362
384
|
if (subcommand === "whoami") {
|
|
@@ -377,6 +399,7 @@ async function main() {
|
|
|
377
399
|
console.log(`Email: ${info.email}`);
|
|
378
400
|
console.log(`Org: ${info.organization_name} (${info.organization_id})`);
|
|
379
401
|
}
|
|
402
|
+
console.error(`Config: ${getConfigPath()}`);
|
|
380
403
|
return;
|
|
381
404
|
}
|
|
382
405
|
if (subcommand === "logout") {
|
|
@@ -495,6 +518,7 @@ async function main() {
|
|
|
495
518
|
}
|
|
496
519
|
}
|
|
497
520
|
}
|
|
521
|
+
console.error(`Config: ${getConfigPath()}`);
|
|
498
522
|
return;
|
|
499
523
|
}
|
|
500
524
|
// Ensure workspace is resolved for all remaining commands
|
package/dist/src/config.d.ts
CHANGED
|
@@ -5,10 +5,22 @@ export interface LoticsConfig {
|
|
|
5
5
|
last_update_check?: number;
|
|
6
6
|
latest_version?: string;
|
|
7
7
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Where a config operation reads from or writes to.
|
|
10
|
+
*
|
|
11
|
+
* - `"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.
|
|
15
|
+
* - `"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.
|
|
18
|
+
*/
|
|
19
|
+
export type ConfigScope = "auto" | "local";
|
|
20
|
+
export declare function loadConfig(scope?: ConfigScope): LoticsConfig | null;
|
|
21
|
+
export declare function saveConfig(config: LoticsConfig, scope?: ConfigScope): void;
|
|
22
|
+
export declare function deleteConfig(scope?: ConfigScope): void;
|
|
23
|
+
export declare function getConfigPath(scope?: ConfigScope): string;
|
|
12
24
|
/**
|
|
13
25
|
* Check for a newer CLI version. Synchronous — prints a warning to stderr
|
|
14
26
|
* if the cached latest version is newer than current. Kicks off a background
|
package/dist/src/config.js
CHANGED
|
@@ -1,31 +1,59 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the config file for a scope.
|
|
6
|
+
*
|
|
7
|
+
* Resolution keys on the `config.json` *file*, never the `.lotics` directory:
|
|
8
|
+
* scaffolded custom-code apps already use `.lotics/` for generated types, so
|
|
9
|
+
* directory presence cannot signal a local config.
|
|
10
|
+
*/
|
|
11
|
+
function configFileForScope(scope) {
|
|
12
|
+
if (scope === "local") {
|
|
13
|
+
return path.join(process.cwd(), ".lotics", "config.json");
|
|
14
|
+
}
|
|
15
|
+
let dir = process.cwd();
|
|
16
|
+
for (;;) {
|
|
17
|
+
const candidate = path.join(dir, ".lotics", "config.json");
|
|
18
|
+
if (fs.existsSync(candidate)) {
|
|
19
|
+
return candidate;
|
|
20
|
+
}
|
|
21
|
+
const parent = path.dirname(dir);
|
|
22
|
+
if (parent === dir) {
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
dir = parent;
|
|
26
|
+
}
|
|
27
|
+
return path.join(os.homedir(), ".lotics", "config.json");
|
|
28
|
+
}
|
|
29
|
+
export function loadConfig(scope = "auto") {
|
|
7
30
|
try {
|
|
8
|
-
const raw = fs.readFileSync(
|
|
31
|
+
const raw = fs.readFileSync(configFileForScope(scope), "utf-8");
|
|
9
32
|
return JSON.parse(raw);
|
|
10
33
|
}
|
|
11
34
|
catch {
|
|
12
35
|
return null;
|
|
13
36
|
}
|
|
14
37
|
}
|
|
15
|
-
export function saveConfig(config) {
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
43
|
+
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);
|
|
18
46
|
}
|
|
19
|
-
export function deleteConfig() {
|
|
47
|
+
export function deleteConfig(scope = "auto") {
|
|
20
48
|
try {
|
|
21
|
-
fs.unlinkSync(
|
|
49
|
+
fs.unlinkSync(configFileForScope(scope));
|
|
22
50
|
}
|
|
23
51
|
catch {
|
|
24
52
|
// Already deleted or never existed
|
|
25
53
|
}
|
|
26
54
|
}
|
|
27
|
-
export function getConfigPath() {
|
|
28
|
-
return
|
|
55
|
+
export function getConfigPath(scope = "auto") {
|
|
56
|
+
return configFileForScope(scope);
|
|
29
57
|
}
|
|
30
58
|
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
31
59
|
/**
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { loadConfig, saveConfig, deleteConfig, getConfigPath } from "./config.js";
|
|
6
|
+
/**
|
|
7
|
+
* Config resolution walks up from the current working directory: the first
|
|
8
|
+
* ancestor with a `.lotics/config.json` wins, otherwise the global
|
|
9
|
+
* `~/.lotics/config.json`. This lets a project or worktree pin its own
|
|
10
|
+
* account/workspace and keeps resolution stable when commands run from a
|
|
11
|
+
* subdirectory. Scope `"local"` targets the current directory exactly — used
|
|
12
|
+
* to bootstrap a per-directory config before any local file exists.
|
|
13
|
+
*
|
|
14
|
+
* These tests pin both `os.homedir()` and `process.cwd()` to temp dirs so the
|
|
15
|
+
* real home directory is never touched.
|
|
16
|
+
*/
|
|
17
|
+
describe("config file resolution", () => {
|
|
18
|
+
let homeDir;
|
|
19
|
+
let projectDir;
|
|
20
|
+
let originalCwd;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
originalCwd = process.cwd();
|
|
23
|
+
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "lotics-home-"));
|
|
24
|
+
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "lotics-project-"));
|
|
25
|
+
vi.spyOn(os, "homedir").mockReturnValue(homeDir);
|
|
26
|
+
process.chdir(projectDir);
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
process.chdir(originalCwd);
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
32
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
const globalFile = () => path.join(homeDir, ".lotics", "config.json");
|
|
35
|
+
const localFile = (dir = projectDir) => path.join(dir, ".lotics", "config.json");
|
|
36
|
+
function write(file, data) {
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
38
|
+
fs.writeFileSync(file, JSON.stringify(data));
|
|
39
|
+
}
|
|
40
|
+
function read(file) {
|
|
41
|
+
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
42
|
+
}
|
|
43
|
+
function chdirInto(...segments) {
|
|
44
|
+
const dir = path.join(projectDir, ...segments);
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
process.chdir(dir);
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
it("returns null when no config exists anywhere", () => {
|
|
50
|
+
expect(loadConfig()).toBeNull();
|
|
51
|
+
});
|
|
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");
|
|
55
|
+
expect(getConfigPath()).toBe(globalFile());
|
|
56
|
+
});
|
|
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");
|
|
61
|
+
expect(getConfigPath()).toBe(localFile());
|
|
62
|
+
});
|
|
63
|
+
it("walks up to an ancestor's config when run from a subdirectory", () => {
|
|
64
|
+
write(localFile(), { api_key: "ltk_project_root" });
|
|
65
|
+
chdirInto("backend", "features");
|
|
66
|
+
expect(loadConfig()?.api_key).toBe("ltk_project_root");
|
|
67
|
+
expect(getConfigPath()).toBe(localFile());
|
|
68
|
+
});
|
|
69
|
+
it("auto saveConfig writes the resolved ancestor file, not the subdirectory", () => {
|
|
70
|
+
write(localFile(), { api_key: "ltk_old" });
|
|
71
|
+
const nested = chdirInto("sub");
|
|
72
|
+
saveConfig({ api_key: "ltk_new" });
|
|
73
|
+
expect(read(localFile()).api_key).toBe("ltk_new");
|
|
74
|
+
expect(fs.existsSync(localFile(nested))).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
it("auto saveConfig writes the global config when no local config exists", () => {
|
|
77
|
+
saveConfig({ api_key: "ltk_new" });
|
|
78
|
+
expect(fs.existsSync(localFile())).toBe(false);
|
|
79
|
+
expect(read(globalFile()).api_key).toBe("ltk_new");
|
|
80
|
+
});
|
|
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");
|
|
84
|
+
expect(getConfigPath("local")).toBe(localFile());
|
|
85
|
+
});
|
|
86
|
+
it("saveConfig writes the credentials file owner-only (0600)", () => {
|
|
87
|
+
saveConfig({ api_key: "ltk_secret" }, "local");
|
|
88
|
+
expect(fs.statSync(localFile()).mode & 0o777).toBe(0o600);
|
|
89
|
+
});
|
|
90
|
+
it("saveConfig tightens an over-permissive pre-existing config file", () => {
|
|
91
|
+
write(localFile(), { api_key: "ltk_old" });
|
|
92
|
+
fs.chmodSync(localFile(), 0o644);
|
|
93
|
+
saveConfig({ api_key: "ltk_new" }, "local");
|
|
94
|
+
expect(fs.statSync(localFile()).mode & 0o777).toBe(0o600);
|
|
95
|
+
});
|
|
96
|
+
it("loadConfig('local') never inherits the global config", () => {
|
|
97
|
+
write(globalFile(), { api_key: "ltk_global" });
|
|
98
|
+
expect(loadConfig("local")).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
it("loadConfig('local') ignores an ancestor's config — it reads the exact cwd only", () => {
|
|
101
|
+
write(localFile(), { api_key: "ltk_root" });
|
|
102
|
+
chdirInto("sub");
|
|
103
|
+
expect(loadConfig("local")).toBeNull();
|
|
104
|
+
});
|
|
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" });
|
|
108
|
+
deleteConfig();
|
|
109
|
+
expect(fs.existsSync(localFile())).toBe(false);
|
|
110
|
+
expect(fs.existsSync(globalFile())).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -53,7 +53,7 @@ export function buildWrapperPage(args) {
|
|
|
53
53
|
<span>workspace <code>${escapeHtml(workspace_id)}</code></span>
|
|
54
54
|
<span>RPC → <code>${escapeHtml(api_url)}</code></span>
|
|
55
55
|
</header>
|
|
56
|
-
<iframe id="app" sandbox="allow-scripts" src="${escapeAttr(vite_url)}"></iframe>
|
|
56
|
+
<iframe id="app" sandbox="allow-scripts" allow="clipboard-write" src="${escapeAttr(vite_url)}"></iframe>
|
|
57
57
|
<script>
|
|
58
58
|
(function () {
|
|
59
59
|
const APP_ID = ${JSON.stringify(app_id)};
|