@lotics/cli 0.88.0 → 0.89.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/render_page.js +100 -100
- package/dist/src/cli.js +162 -100
- package/dist/{client.d.ts → src/client.d.ts} +9 -1
- package/dist/src/package_content_types.d.ts +70 -0
- package/dist/src/package_content_types.js +12 -0
- package/package.json +2 -2
- package/dist/app_commands.d.ts +0 -401
- package/dist/app_commands.js +0 -1520
- package/dist/app_commands.test.d.ts +0 -1
- package/dist/app_commands.test.js +0 -898
- package/dist/app_workflow_check.d.ts +0 -77
- package/dist/app_workflow_check.js +0 -169
- package/dist/app_workflow_check.test.d.ts +0 -1
- package/dist/app_workflow_check.test.js +0 -166
- package/dist/args.d.ts +0 -42
- package/dist/args.js +0 -121
- package/dist/args.test.d.ts +0 -1
- package/dist/args.test.js +0 -88
- package/dist/child_env.d.ts +0 -13
- package/dist/child_env.js +0 -24
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -1095
- package/dist/cli_dispatch.test.d.ts +0 -1
- package/dist/cli_dispatch.test.js +0 -89
- package/dist/client.test.d.ts +0 -1
- package/dist/client.test.js +0 -47
- package/dist/config.d.ts +0 -124
- package/dist/config.js +0 -315
- package/dist/config.test.d.ts +0 -1
- package/dist/config.test.js +0 -319
- package/dist/dev/file_relay.d.ts +0 -39
- package/dist/dev/file_relay.js +0 -87
- package/dist/dev/file_relay.test.d.ts +0 -1
- package/dist/dev/file_relay.test.js +0 -87
- package/dist/dev/rpc_handler.d.ts +0 -21
- package/dist/dev/rpc_handler.js +0 -166
- package/dist/dev/rpc_handler.test.d.ts +0 -1
- package/dist/dev/rpc_handler.test.js +0 -107
- package/dist/dev/server.d.ts +0 -75
- package/dist/dev/server.js +0 -443
- package/dist/dev/upload_relay.d.ts +0 -36
- package/dist/dev/upload_relay.js +0 -61
- package/dist/dev/upload_relay.test.d.ts +0 -1
- package/dist/dev/upload_relay.test.js +0 -76
- package/dist/dev/wrapper_page.d.ts +0 -40
- package/dist/dev/wrapper_page.js +0 -335
- package/dist/docx.d.ts +0 -2
- package/dist/docx.js +0 -341
- package/dist/docx.test.d.ts +0 -1
- package/dist/docx.test.js +0 -145
- package/dist/file_command_io.d.ts +0 -12
- package/dist/file_command_io.js +0 -34
- package/dist/generate_app_agents_dts.d.ts +0 -16
- package/dist/generate_app_agents_dts.js +0 -62
- package/dist/generate_app_agents_dts.test.d.ts +0 -1
- package/dist/generate_app_agents_dts.test.js +0 -45
- package/dist/generate_app_fields.d.ts +0 -56
- package/dist/generate_app_fields.js +0 -148
- package/dist/generate_app_fields.test.d.ts +0 -1
- package/dist/generate_app_fields.test.js +0 -108
- package/dist/generate_app_queries_dts.d.ts +0 -21
- package/dist/generate_app_queries_dts.js +0 -57
- package/dist/generate_app_workflows_dts.d.ts +0 -39
- package/dist/generate_app_workflows_dts.js +0 -229
- package/dist/generate_app_workflows_dts.test.d.ts +0 -1
- package/dist/generate_app_workflows_dts.test.js +0 -109
- package/dist/generate_package_fields.d.ts +0 -49
- package/dist/generate_package_fields.js +0 -157
- package/dist/generate_package_fields.test.d.ts +0 -1
- package/dist/generate_package_fields.test.js +0 -59
- package/dist/inputs.d.ts +0 -38
- package/dist/inputs.js +0 -50
- package/dist/inputs.test.d.ts +0 -1
- package/dist/inputs.test.js +0 -89
- package/dist/preview.d.ts +0 -3
- package/dist/preview.js +0 -233
- package/dist/starter_template.d.ts +0 -64
- package/dist/starter_template.js +0 -753
- package/dist/starter_template.test.d.ts +0 -1
- package/dist/starter_template.test.js +0 -115
- package/dist/version.d.ts +0 -1
- package/dist/version.js +0 -6
- package/dist/xlsx.d.ts +0 -2
- package/dist/xlsx.js +0 -489
- package/dist/xlsx.test.d.ts +0 -1
- package/dist/xlsx.test.js +0 -131
- /package/dist/{client.js → src/client.js} +0 -0
package/dist/config.test.js
DELETED
|
@@ -1,319 +0,0 @@
|
|
|
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, loadGlobalConfig, resolveContext, resolveProfileByNameOrId, upsertProfile, removeProfile, setActiveOrg, setSelectedWorkspace, } 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(), { active_org: "org_global" });
|
|
54
|
-
expect(loadConfig()?.active_org).toBe("org_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(), { active_org: "org_global" });
|
|
59
|
-
write(localFile(), { active_org: "org_local" });
|
|
60
|
-
expect(loadConfig()?.active_org).toBe("org_local");
|
|
61
|
-
expect(getConfigPath()).toBe(localFile());
|
|
62
|
-
});
|
|
63
|
-
it("walks up to an ancestor's config when run from a subdirectory", () => {
|
|
64
|
-
write(localFile(), { active_org: "org_project_root" });
|
|
65
|
-
chdirInto("backend", "features");
|
|
66
|
-
expect(loadConfig()?.active_org).toBe("org_project_root");
|
|
67
|
-
expect(getConfigPath()).toBe(localFile());
|
|
68
|
-
});
|
|
69
|
-
it("auto saveConfig writes the resolved ancestor file, not the subdirectory", () => {
|
|
70
|
-
write(localFile(), { active_org: "org_old" });
|
|
71
|
-
const nested = chdirInto("sub");
|
|
72
|
-
saveConfig({ active_org: "org_new" });
|
|
73
|
-
expect(read(localFile()).active_org).toBe("org_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({ active_org: "org_new" });
|
|
78
|
-
expect(fs.existsSync(localFile())).toBe(false);
|
|
79
|
-
expect(read(globalFile()).active_org).toBe("org_new");
|
|
80
|
-
});
|
|
81
|
-
it("scope 'local' creates ./.lotics/config.json even when none existed", () => {
|
|
82
|
-
saveConfig({ active_org: "org_pinned" }, "local");
|
|
83
|
-
expect(read(localFile()).active_org).toBe("org_pinned");
|
|
84
|
-
expect(getConfigPath("local")).toBe(localFile());
|
|
85
|
-
});
|
|
86
|
-
it("saveConfig writes the credentials file owner-only (0600)", () => {
|
|
87
|
-
saveConfig({ active_org: "org_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(), { active_org: "org_old" });
|
|
92
|
-
fs.chmodSync(localFile(), 0o644);
|
|
93
|
-
saveConfig({ active_org: "org_new" }, "local");
|
|
94
|
-
expect(fs.statSync(localFile()).mode & 0o777).toBe(0o600);
|
|
95
|
-
});
|
|
96
|
-
it("loadConfig('local') never inherits the global config", () => {
|
|
97
|
-
write(globalFile(), { active_org: "org_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(), { active_org: "org_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(), { active_org: "org_global" });
|
|
107
|
-
write(localFile(), { active_org: "org_local" });
|
|
108
|
-
deleteConfig();
|
|
109
|
-
expect(fs.existsSync(localFile())).toBe(false);
|
|
110
|
-
expect(fs.existsSync(globalFile())).toBe(true);
|
|
111
|
-
});
|
|
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
|
-
});
|
package/dist/dev/file_relay.d.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dev-only GET relay: the read half of the same problem `upload_relay.ts` solves.
|
|
3
|
-
*
|
|
4
|
-
* The server presigns every file cell an app query returns (`url` / `thumbnail_url` /
|
|
5
|
-
* `preview_url` on each file object), and those URLs point at the PROD bucket, whose
|
|
6
|
-
* CORS admits `https://*.lotics.app` and not `http://localhost:<port>`. Displaying
|
|
7
|
-
* such a URL is fine — `<img>` and `<video>` loads are not CORS-gated, and neither is
|
|
8
|
-
* the top-level navigation `openExternal` performs. But **fetching the bytes** is, and
|
|
9
|
-
* that is exactly what every preview engine does (PDF, Word, Excel all read the file
|
|
10
|
-
* into memory), so file preview could never work in `lotics app dev`.
|
|
11
|
-
*
|
|
12
|
-
* So the dev server hands the app same-origin URLs and fetches the bytes itself.
|
|
13
|
-
*
|
|
14
|
-
* The security property is the one that governs the upload relay: the app never names
|
|
15
|
-
* a destination. It receives an opaque token, and the relay reads ONLY from a URL the
|
|
16
|
-
* server itself observed the API return for that token. No client-controlled target,
|
|
17
|
-
* nothing to allowlist, no SSRF surface.
|
|
18
|
-
*
|
|
19
|
-
* Production is untouched: it serves presigned URLs straight from storage.
|
|
20
|
-
*/
|
|
21
|
-
export interface FileRelay {
|
|
22
|
-
/**
|
|
23
|
-
* Deep-copy an RPC result with every presigned file URL swapped for one served by
|
|
24
|
-
* this dev server. Immutable: the input is never mutated. Anything that isn't a
|
|
25
|
-
* presigned URL on a file object passes through byte-identical.
|
|
26
|
-
*/
|
|
27
|
-
rewrite(result: unknown): unknown;
|
|
28
|
-
/** The URL this token stands for — `null` if unknown or expired. */
|
|
29
|
-
destinationFor(token: string): string | null;
|
|
30
|
-
/** Live tokens — for tests. */
|
|
31
|
-
size(): number;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* `wrapperOrigin` — e.g. `http://localhost:5174`. The URL must be ABSOLUTE: these
|
|
35
|
-
* URLs are consumed inside the app **iframe**, which is served from Vite's origin, so
|
|
36
|
-
* a relative `/_file/…` would resolve against Vite and 404. (The upload relay's URL is
|
|
37
|
-
* consumed by the wrapper page itself, where relative is correct.)
|
|
38
|
-
*/
|
|
39
|
-
export declare function createFileRelay(wrapperOrigin: string, now?: () => number): FileRelay;
|
package/dist/dev/file_relay.js
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dev-only GET relay: the read half of the same problem `upload_relay.ts` solves.
|
|
3
|
-
*
|
|
4
|
-
* The server presigns every file cell an app query returns (`url` / `thumbnail_url` /
|
|
5
|
-
* `preview_url` on each file object), and those URLs point at the PROD bucket, whose
|
|
6
|
-
* CORS admits `https://*.lotics.app` and not `http://localhost:<port>`. Displaying
|
|
7
|
-
* such a URL is fine — `<img>` and `<video>` loads are not CORS-gated, and neither is
|
|
8
|
-
* the top-level navigation `openExternal` performs. But **fetching the bytes** is, and
|
|
9
|
-
* that is exactly what every preview engine does (PDF, Word, Excel all read the file
|
|
10
|
-
* into memory), so file preview could never work in `lotics app dev`.
|
|
11
|
-
*
|
|
12
|
-
* So the dev server hands the app same-origin URLs and fetches the bytes itself.
|
|
13
|
-
*
|
|
14
|
-
* The security property is the one that governs the upload relay: the app never names
|
|
15
|
-
* a destination. It receives an opaque token, and the relay reads ONLY from a URL the
|
|
16
|
-
* server itself observed the API return for that token. No client-controlled target,
|
|
17
|
-
* nothing to allowlist, no SSRF surface.
|
|
18
|
-
*
|
|
19
|
-
* Production is untouched: it serves presigned URLs straight from storage.
|
|
20
|
-
*/
|
|
21
|
-
import { createHash } from "node:crypto";
|
|
22
|
-
/** Presigned GETs live ~24h; a token outlives its URL by nothing. */
|
|
23
|
-
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
24
|
-
/** A long dev session re-queries constantly; each query re-presigns. Bound the table. */
|
|
25
|
-
const MAX_ENTRIES = 5000;
|
|
26
|
-
/**
|
|
27
|
-
* The keys the backend presigns on a file object (`file_url_resolver.ts`). Only these,
|
|
28
|
-
* and only on something that is actually a file, get rewritten — a record's own text
|
|
29
|
-
* cell holding a link (a public app URL, say) must keep pointing where it points.
|
|
30
|
-
*/
|
|
31
|
-
const PRESIGNED_KEYS = new Set(["url", "thumbnail_url", "preview_url"]);
|
|
32
|
-
const isFileObject = (o) => typeof o.filename === "string" && typeof o.mime_type === "string";
|
|
33
|
-
const isRemoteUrl = (v) => typeof v === "string" && /^https?:\/\//i.test(v);
|
|
34
|
-
/**
|
|
35
|
-
* `wrapperOrigin` — e.g. `http://localhost:5174`. The URL must be ABSOLUTE: these
|
|
36
|
-
* URLs are consumed inside the app **iframe**, which is served from Vite's origin, so
|
|
37
|
-
* a relative `/_file/…` would resolve against Vite and 404. (The upload relay's URL is
|
|
38
|
-
* consumed by the wrapper page itself, where relative is correct.)
|
|
39
|
-
*/
|
|
40
|
-
export function createFileRelay(wrapperOrigin, now = Date.now) {
|
|
41
|
-
const seen = new Map();
|
|
42
|
-
// A stable token per URL: the same file re-queried doesn't mint a new entry, so the
|
|
43
|
-
// table tracks distinct presigns, not query volume. (Truncated — this is a lookup
|
|
44
|
-
// key in a localhost-only process, not a secret.)
|
|
45
|
-
const tokenFor = (url) => createHash("sha256").update(url).digest("hex").slice(0, 24);
|
|
46
|
-
const remember = (url) => {
|
|
47
|
-
const token = tokenFor(url);
|
|
48
|
-
seen.set(token, { url, at: now() });
|
|
49
|
-
if (seen.size > MAX_ENTRIES) {
|
|
50
|
-
// Insertion-ordered: drop the oldest.
|
|
51
|
-
const oldest = seen.keys().next();
|
|
52
|
-
if (!oldest.done)
|
|
53
|
-
seen.delete(oldest.value);
|
|
54
|
-
}
|
|
55
|
-
return token;
|
|
56
|
-
};
|
|
57
|
-
const walk = (node) => {
|
|
58
|
-
if (Array.isArray(node))
|
|
59
|
-
return node.map(walk);
|
|
60
|
-
if (!node || typeof node !== "object")
|
|
61
|
-
return node;
|
|
62
|
-
const obj = node;
|
|
63
|
-
const file = isFileObject(obj);
|
|
64
|
-
const out = {};
|
|
65
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
66
|
-
out[key] =
|
|
67
|
-
file && PRESIGNED_KEYS.has(key) && isRemoteUrl(value)
|
|
68
|
-
? `${wrapperOrigin}/_file/${remember(value)}`
|
|
69
|
-
: walk(value);
|
|
70
|
-
}
|
|
71
|
-
return out;
|
|
72
|
-
};
|
|
73
|
-
return {
|
|
74
|
-
rewrite: (result) => walk(result),
|
|
75
|
-
destinationFor(token) {
|
|
76
|
-
const entry = seen.get(token);
|
|
77
|
-
if (!entry)
|
|
78
|
-
return null;
|
|
79
|
-
if (now() - entry.at > TTL_MS) {
|
|
80
|
-
seen.delete(token);
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
return entry.url;
|
|
84
|
-
},
|
|
85
|
-
size: () => seen.size,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { createFileRelay } from "./file_relay.js";
|
|
3
|
-
const ORIGIN = "http://localhost:5174";
|
|
4
|
-
const PRESIGNED = "https://production.r2.cloudflarestorage.com/org/files/fil_a/photo.jpg?X-Amz-Signature=abc";
|
|
5
|
-
const THUMB = "https://production.r2.cloudflarestorage.com/org/files/fil_a/thumb.jpg?X-Amz-Signature=def";
|
|
6
|
-
const fileCell = {
|
|
7
|
-
id: "fil_a",
|
|
8
|
-
filename: "photo.jpg",
|
|
9
|
-
mime_type: "image/jpeg",
|
|
10
|
-
url: PRESIGNED,
|
|
11
|
-
thumbnail_url: THUMB,
|
|
12
|
-
};
|
|
13
|
-
/** A query result, shaped like the real thing: rows, each with a files cell. */
|
|
14
|
-
const queryResult = { rows: [{ id: "rec_1", giay_to: [fileCell] }] };
|
|
15
|
-
describe("file relay — the app reads bytes from us, not from storage", () => {
|
|
16
|
-
it("rewrites every presigned URL on a file cell to an absolute URL on the dev server", () => {
|
|
17
|
-
const relay = createFileRelay(ORIGIN);
|
|
18
|
-
const out = relay.rewrite(queryResult);
|
|
19
|
-
const cell = out.rows[0].giay_to[0];
|
|
20
|
-
// ABSOLUTE: the app runs in the Vite-origin iframe, so a relative path would
|
|
21
|
-
// resolve against Vite and 404.
|
|
22
|
-
expect(cell.url).toMatch(/^http:\/\/localhost:5174\/_file\/[a-f0-9]{24}$/);
|
|
23
|
-
expect(cell.thumbnail_url).toMatch(/^http:\/\/localhost:5174\/_file\/[a-f0-9]{24}$/);
|
|
24
|
-
expect(cell.url).not.toBe(cell.thumbnail_url);
|
|
25
|
-
});
|
|
26
|
-
it("reads back the exact storage URL the token stands for", () => {
|
|
27
|
-
const relay = createFileRelay(ORIGIN);
|
|
28
|
-
const out = relay.rewrite(queryResult);
|
|
29
|
-
const token = out.rows[0].giay_to[0].url.split("/_file/")[1];
|
|
30
|
-
expect(relay.destinationFor(token)).toBe(PRESIGNED);
|
|
31
|
-
});
|
|
32
|
-
it("leaves the rest of the payload byte-identical and never mutates the input", () => {
|
|
33
|
-
const relay = createFileRelay(ORIGIN);
|
|
34
|
-
const out = relay.rewrite(queryResult);
|
|
35
|
-
expect(out.rows[0].id).toBe("rec_1");
|
|
36
|
-
expect(out.rows[0].giay_to[0].filename).toBe("photo.jpg");
|
|
37
|
-
expect(fileCell.url).toBe(PRESIGNED); // input untouched
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
describe("file relay — it rewrites file URLs, not every URL it sees", () => {
|
|
41
|
-
it("does NOT touch a link that lives in a record's own text cell", () => {
|
|
42
|
-
// The customer's "Link tự khai" field holds a real URL the user clicks. Relaying it
|
|
43
|
-
// would send them to the dev server instead of the site.
|
|
44
|
-
const relay = createFileRelay(ORIGIN);
|
|
45
|
-
const row = { id: "rec_1", link_tu_khai: "https://dang-ky-noxh.lotics.app?kh=KH-2026-919" };
|
|
46
|
-
const out = relay.rewrite({ rows: [row] });
|
|
47
|
-
expect(out.rows[0].link_tu_khai).toBe("https://dang-ky-noxh.lotics.app?kh=KH-2026-919");
|
|
48
|
-
expect(relay.size()).toBe(0);
|
|
49
|
-
});
|
|
50
|
-
it("does NOT touch a `url` key on something that isn't a file", () => {
|
|
51
|
-
const relay = createFileRelay(ORIGIN);
|
|
52
|
-
const out = relay.rewrite({ webhook: { url: "https://example.com/hook" } });
|
|
53
|
-
expect(out.webhook.url).toBe("https://example.com/hook");
|
|
54
|
-
expect(relay.size()).toBe(0);
|
|
55
|
-
});
|
|
56
|
-
it("reaches file cells wherever they are nested — a workflow's generated files, too", () => {
|
|
57
|
-
const relay = createFileRelay(ORIGIN);
|
|
58
|
-
const out = relay.rewrite({
|
|
59
|
-
data: { status: "ok" },
|
|
60
|
-
files: [{ id: "fil_z", filename: "don.docx", mime_type: "application/vnd...", url: PRESIGNED }],
|
|
61
|
-
});
|
|
62
|
-
expect(out.files[0].url).toContain("/_file/");
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
describe("file relay — it reads ONLY what it handed out", () => {
|
|
66
|
-
it("refuses a token it never minted", () => {
|
|
67
|
-
const relay = createFileRelay(ORIGIN);
|
|
68
|
-
expect(relay.destinationFor("deadbeefdeadbeefdeadbeef")).toBeNull();
|
|
69
|
-
});
|
|
70
|
-
it("refuses a token whose presign has aged out", () => {
|
|
71
|
-
let clock = 0;
|
|
72
|
-
const relay = createFileRelay(ORIGIN, () => clock);
|
|
73
|
-
const out = relay.rewrite(queryResult);
|
|
74
|
-
const token = out.rows[0].giay_to[0].url.split("/_file/")[1];
|
|
75
|
-
clock += 25 * 60 * 60 * 1000; // presigned GETs live ~24h
|
|
76
|
-
expect(relay.destinationFor(token)).toBeNull();
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
describe("file relay — a long dev session re-queries constantly", () => {
|
|
80
|
-
it("mints one stable token per URL rather than growing on every query", () => {
|
|
81
|
-
const relay = createFileRelay(ORIGIN);
|
|
82
|
-
const a = relay.rewrite(queryResult);
|
|
83
|
-
const b = relay.rewrite(queryResult);
|
|
84
|
-
expect(b.rows[0].giay_to[0].url).toBe(a.rows[0].giay_to[0].url);
|
|
85
|
-
expect(relay.size()).toBe(2); // the file and its thumbnail — not four
|
|
86
|
-
});
|
|
87
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stateless dispatcher: takes a parsed RPC envelope from the wrapper page
|
|
3
|
-
* and routes to the matching LoticsClient method.
|
|
4
|
-
*
|
|
5
|
-
* Errors are thrown — the HTTP server caller serializes them to a 500 with
|
|
6
|
-
* { message }. Same shape as the production iframe-host's error path.
|
|
7
|
-
*/
|
|
8
|
-
import { LoticsClient } from "../client.js";
|
|
9
|
-
export type RpcOp = "query" | "field_options" | "workflow" | "members" | "context" | "binding" | "upload_url" | "upload_complete" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
10
|
-
export interface RpcRequest {
|
|
11
|
-
app_id: string;
|
|
12
|
-
op: RpcOp;
|
|
13
|
-
payload: unknown;
|
|
14
|
-
}
|
|
15
|
-
/** Per-app facts the dev server knows from the local manifest that the
|
|
16
|
-
* production iframe-host supplies from the embedding context. */
|
|
17
|
-
export interface DispatchOptions {
|
|
18
|
-
/** From `package.json#lotics.capabilities.comments`. */
|
|
19
|
-
commentsEnabled?: boolean;
|
|
20
|
-
}
|
|
21
|
-
export declare function dispatchRpc(client: LoticsClient, body: RpcRequest, opts?: DispatchOptions): Promise<unknown>;
|