jitsu-cli 2.14.0-beta.10 → 2.14.0-beta.101

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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/bin/jitsu +3 -0
  3. package/build.mts +39 -0
  4. package/compiled/package.json +16 -22
  5. package/compiled/src/commands/build.js +16 -24
  6. package/compiled/src/commands/config/handlers.js +292 -0
  7. package/compiled/src/commands/config/index.js +148 -0
  8. package/compiled/src/commands/config/resources.js +88 -0
  9. package/compiled/src/commands/default-workspace.js +39 -0
  10. package/compiled/src/commands/deploy.js +55 -19
  11. package/compiled/src/commands/spec.js +31 -0
  12. package/compiled/src/index.js +21 -2
  13. package/compiled/src/lib/api-client.js +52 -0
  14. package/compiled/src/lib/auth-file.js +67 -0
  15. package/compiled/src/lib/body-builder.js +53 -0
  16. package/compiled/src/lib/body-fields.js +47 -0
  17. package/compiled/src/lib/compiled-function.js +23 -21
  18. package/compiled/src/lib/dotted.js +34 -0
  19. package/compiled/src/lib/renderer.js +33 -0
  20. package/compiled/src/lib/spec.js +11 -0
  21. package/dist/main.js +46422 -92895
  22. package/dist/main.js.map +7 -1
  23. package/package.json +37 -43
  24. package/src/commands/build.ts +21 -26
  25. package/src/commands/config/handlers.ts +339 -0
  26. package/src/commands/config/index.ts +171 -0
  27. package/src/commands/config/resources.ts +110 -0
  28. package/src/commands/default-workspace.ts +44 -0
  29. package/src/commands/deploy.ts +96 -20
  30. package/src/commands/spec.ts +32 -0
  31. package/src/index.ts +34 -17
  32. package/src/lib/api-client.ts +64 -0
  33. package/src/lib/auth-file.ts +83 -0
  34. package/src/lib/body-builder.ts +68 -0
  35. package/src/lib/body-fields.ts +61 -0
  36. package/src/lib/compiled-function.ts +30 -23
  37. package/src/lib/dotted.ts +43 -0
  38. package/src/lib/renderer.ts +44 -0
  39. package/src/lib/spec.ts +32 -0
  40. package/dist/140.js +0 -452
  41. package/dist/140.js.map +0 -1
  42. package/webpack.config.cjs +0 -53
@@ -0,0 +1,110 @@
1
+ // Single source of truth for the `jitsu config <noun>` command tree.
2
+ // `kind` selects which verb handlers apply; `type` is the segment used in
3
+ // /api/{workspaceId}/config/{type} for standard config objects.
4
+
5
+ export type ResourceKind = "configObject" | "workspace" | "link" | "profile-builder";
6
+
7
+ export type Resource = {
8
+ noun: string;
9
+ aliases: string[];
10
+ kind: ResourceKind;
11
+ type?: string;
12
+ supportsTest?: boolean;
13
+ // Description used in `--help`.
14
+ description: string;
15
+ };
16
+
17
+ export const resources: Resource[] = [
18
+ {
19
+ noun: "workspaces",
20
+ aliases: ["workspace"],
21
+ kind: "workspace",
22
+ description: "Workspaces accessible to the current user",
23
+ },
24
+ {
25
+ noun: "destinations",
26
+ aliases: ["destination", "dest"],
27
+ kind: "configObject",
28
+ type: "destination",
29
+ supportsTest: true,
30
+ description: "Destinations (warehouses, databases, services receiving events)",
31
+ },
32
+ {
33
+ noun: "streams",
34
+ aliases: ["stream"],
35
+ kind: "configObject",
36
+ type: "stream",
37
+ supportsTest: true,
38
+ description: "Event streams (formerly known as sources)",
39
+ },
40
+ {
41
+ noun: "functions",
42
+ aliases: ["function", "fn"],
43
+ kind: "configObject",
44
+ type: "function",
45
+ description: "User-defined functions (UDFs). For dev workflow see `jitsu deploy`.",
46
+ },
47
+ {
48
+ noun: "services",
49
+ aliases: ["service"],
50
+ kind: "configObject",
51
+ type: "service",
52
+ supportsTest: true,
53
+ description: "External connector services (Airbyte protocol)",
54
+ },
55
+ {
56
+ noun: "domains",
57
+ aliases: ["domain"],
58
+ kind: "configObject",
59
+ type: "domain",
60
+ description: "Custom ingestion domains",
61
+ },
62
+ {
63
+ noun: "misc",
64
+ aliases: [],
65
+ kind: "configObject",
66
+ type: "misc",
67
+ description: "Miscellaneous configuration entities (free-form)",
68
+ },
69
+ {
70
+ noun: "notifications",
71
+ aliases: ["notification"],
72
+ kind: "configObject",
73
+ type: "notification",
74
+ description: "Alert channels (email/Slack)",
75
+ },
76
+ {
77
+ noun: "connections",
78
+ aliases: ["connection", "link", "links"],
79
+ kind: "link",
80
+ description: "Connections between streams/services and destinations",
81
+ },
82
+ {
83
+ noun: "profile-builders",
84
+ aliases: ["profile-builder"],
85
+ kind: "profile-builder",
86
+ description: "Profile builders (identity stitching)",
87
+ },
88
+ ];
89
+
90
+ export type Verb = "list" | "get" | "create" | "update" | "delete" | "test";
91
+
92
+ // Which verbs apply to a given resource kind.
93
+ export function verbsFor(kind: ResourceKind): Verb[] {
94
+ switch (kind) {
95
+ case "configObject":
96
+ return ["list", "get", "create", "update", "delete"];
97
+ case "workspace":
98
+ return ["list", "get", "create", "update", "delete"];
99
+ case "link":
100
+ // link has no per-id GET; list+create(upsert)+update(alias)+delete
101
+ return ["list", "create", "update", "delete"];
102
+ case "profile-builder":
103
+ return ["list", "create", "update", "delete"];
104
+ }
105
+ }
106
+
107
+ export function findResource(name: string): Resource | undefined {
108
+ const lower = name.toLowerCase();
109
+ return resources.find(r => r.noun === lower || r.aliases.includes(lower));
110
+ }
@@ -0,0 +1,44 @@
1
+ import { ApiClient, ApiError } from "../lib/api-client";
2
+ import { authFilePath, readAuthFile, resolveAuth, updateAuthFile } from "../lib/auth-file";
3
+ import { red } from "../lib/chalk-code-highlight";
4
+
5
+ export type DefaultWorkspaceOpts = {
6
+ host?: string;
7
+ apikey?: string;
8
+ };
9
+
10
+ export async function setDefaultWorkspace(idOrSlug: string, opts: DefaultWorkspaceOpts) {
11
+ try {
12
+ const auth = resolveAuth(opts);
13
+ const client = new ApiClient(auth);
14
+ let workspace: { id: string; slug?: string; name?: string };
15
+ try {
16
+ workspace = await client.request({
17
+ method: "GET",
18
+ path: `/api/workspace/${encodeURIComponent(idOrSlug)}`,
19
+ });
20
+ } catch (e) {
21
+ if (e instanceof ApiError && e.status === 404) {
22
+ throw new Error(`Workspace '${idOrSlug}' not found (or you don't have access)`);
23
+ }
24
+ throw e;
25
+ }
26
+ updateAuthFile({ defaultWorkspace: workspace.id });
27
+ const label = workspace.slug ? `${workspace.slug} (${workspace.id})` : workspace.id;
28
+ console.log(`Default workspace set to ${label}.`);
29
+ console.log(`Saved to ${authFilePath()}.`);
30
+ } catch (e) {
31
+ console.error(red(`Error: ${e instanceof Error ? e.message : String(e)}`));
32
+ process.exit(1);
33
+ }
34
+ }
35
+
36
+ export async function unsetDefaultWorkspace() {
37
+ const file = readAuthFile();
38
+ if (!file?.defaultWorkspace) {
39
+ console.log("No default workspace is set.");
40
+ return;
41
+ }
42
+ updateAuthFile({ defaultWorkspace: undefined });
43
+ console.log("Default workspace unset.");
44
+ }
@@ -3,7 +3,6 @@ import { homedir } from "os";
3
3
  import inquirer from "inquirer";
4
4
  import { existsSync, readdirSync, readFileSync } from "fs";
5
5
  import { loadPackageJson } from "./shared";
6
- import fetch from "node-fetch";
7
6
  import cuid from "cuid";
8
7
  import { b, green, red } from "../lib/chalk-code-highlight";
9
8
  import { getFunctionFromFilePath } from "../lib/compiled-function";
@@ -101,12 +100,14 @@ async function deployFunctions(
101
100
  const dir = `dist/${kind}s`;
102
101
  const functionsDir = path.resolve(projectDir, dir);
103
102
 
103
+ if (!existsSync(functionsDir)) {
104
+ console.warn(`No ${b(dir)} directory found, skipping ${kind}s. Please make sure that you have built the project.`);
105
+ return;
106
+ }
104
107
  const functionsFiles = readdirSync(functionsDir);
105
108
  if (functionsFiles.length === 0) {
106
- console.warn(
107
- red(`Can't find function files in ${b(dir)} directory. Please make sure that you have built the project.`)
108
- );
109
- process.exit(1);
109
+ console.warn(`No ${kind} files found in ${b(dir)}, skipping. Please make sure that you have built the project.`);
110
+ return;
110
111
  }
111
112
  const selectedFiles: string[] = [];
112
113
  if (selected) {
@@ -144,6 +145,11 @@ async function deployFunctions(
144
145
  profileBuilders = ((await res.json()) as any).profileBuilders as any[];
145
146
  }
146
147
 
148
+ // Fetch the existing function list once for slug/id resolution. Previously
149
+ // every deployFunction() call refetched the whole list (with code blobs) —
150
+ // O(N) requests per deploy, each pulling all N rows from the console DB.
151
+ const existingFunctions = await fetchExistingFunctions({ host, apikey, workspaceId: workspace.id! });
152
+
147
153
  for (const file of selectedFiles) {
148
154
  console.log(
149
155
  `${b(`𝑓`)} Deploying function ${b(path.basename(file))} to workspace ${workspace.name} (${host}/${
@@ -151,27 +157,98 @@ async function deployFunctions(
151
157
  })`
152
158
  );
153
159
  await deployFunction(
160
+ projectDir,
154
161
  { host, apikey },
155
162
  packageJson,
156
163
  workspace,
157
164
  kind,
158
165
  path.resolve(functionsDir, file),
159
- profileBuilders
166
+ profileBuilders,
167
+ existingFunctions
160
168
  );
161
169
  }
162
170
  }
163
171
 
172
+ // Cache of functions already present in the workspace. byId and bySlug share
173
+ // object identity so a single mutation visible from both. `slug` is tracked
174
+ // per entry so we can detect (and reflect) renames on PUT.
175
+ type ExistingFunction = { id: string; slug?: string };
176
+ type ExistingFunctionsCache = {
177
+ bySlug: Map<string, ExistingFunction>;
178
+ byId: Map<string, ExistingFunction>;
179
+ };
180
+
181
+ async function fetchExistingFunctions({
182
+ host,
183
+ apikey,
184
+ workspaceId,
185
+ }: {
186
+ host?: string;
187
+ apikey?: string;
188
+ workspaceId?: string;
189
+ }): Promise<ExistingFunctionsCache> {
190
+ const res = await fetch(`${host}/api/${workspaceId}/config/function`, {
191
+ headers: { Authorization: `Bearer ${apikey}` },
192
+ });
193
+ if (!res.ok) {
194
+ console.error(red(`Cannot list existing functions:\n${b(await res.text())}`));
195
+ process.exit(1);
196
+ }
197
+ const { objects } = (await res.json()) as { objects: { id: string; slug?: string }[] };
198
+ const bySlug = new Map<string, ExistingFunction>();
199
+ const byId = new Map<string, ExistingFunction>();
200
+ for (const f of objects) {
201
+ const entry: ExistingFunction = { id: f.id, slug: f.slug };
202
+ byId.set(f.id, entry);
203
+ if (f.slug) bySlug.set(f.slug, entry);
204
+ }
205
+ return { bySlug, byId };
206
+ }
207
+
208
+ // Update the cache after a successful POST. Both maps must reflect the new
209
+ // function so a later file with the same slug/id in this same deploy run
210
+ // switches to PUT instead of trying a second POST.
211
+ function cacheAfterCreate(cache: ExistingFunctionsCache, id: string, slug: string | undefined) {
212
+ const entry: ExistingFunction = { id, slug };
213
+ cache.byId.set(id, entry);
214
+ if (slug) cache.bySlug.set(slug, entry);
215
+ }
216
+
217
+ // Update the cache after a successful PUT. Preserves the previous re-fetch
218
+ // semantics for slug renames: the old slug is no longer pointing at this id
219
+ // on the server, so drop it from the slug index and install the new one.
220
+ function cacheAfterUpdate(cache: ExistingFunctionsCache, id: string, newSlug: string | undefined) {
221
+ const entry = cache.byId.get(id);
222
+ if (!entry) {
223
+ // Function existed only on the server, not in our hoisted cache (shouldn't
224
+ // normally happen since the PUT branch only runs when we resolved an id).
225
+ // Insert defensively so later files in this run see it.
226
+ cacheAfterCreate(cache, id, newSlug);
227
+ return;
228
+ }
229
+ if (entry.slug && entry.slug !== newSlug) {
230
+ cache.bySlug.delete(entry.slug);
231
+ }
232
+ entry.slug = newSlug;
233
+ if (newSlug) cache.bySlug.set(newSlug, entry);
234
+ }
235
+
164
236
  async function deployFunction(
237
+ projectDir: string,
165
238
  { host, apikey }: Args,
166
239
  packageJson: any,
167
240
  workspace: Workspace,
168
241
  kind: "function" | "profile",
169
242
  file: string,
170
- profileBuilders: any[] = []
243
+ profileBuilders: any[] = [],
244
+ existingFunctions: ExistingFunctionsCache = {
245
+ bySlug: new Map(),
246
+ byId: new Map(),
247
+ }
171
248
  ) {
172
249
  const code = readFileSync(file, "utf-8");
173
250
 
174
- const wrapped = await getFunctionFromFilePath(file, kind, profileBuilders);
251
+ const wrapped = await getFunctionFromFilePath(projectDir, file, kind, profileBuilders);
175
252
  const meta = wrapped.meta;
176
253
  if (meta) {
177
254
  console.log(` meta: slug=${meta.slug}, name=${meta.name || "not set"}`);
@@ -181,18 +258,8 @@ async function deployFunction(
181
258
  }
182
259
  let existingFunctionId: string | undefined;
183
260
  if (meta.slug) {
184
- const res = await fetch(`${host}/api/${workspace.id}/config/function`, {
185
- headers: {
186
- Authorization: `Bearer ${apikey}`,
187
- },
188
- });
189
- if (!res.ok) {
190
- console.error(red(`Cannot add function to workspace:\n${b(await res.text())}`));
191
- process.exit(1);
192
- } else {
193
- const existing = (await res.json()) as any;
194
- existingFunctionId = existing.objects.find(f => f.slug === meta.slug || f.id === meta.id)?.id;
195
- }
261
+ existingFunctionId =
262
+ existingFunctions.bySlug.get(meta.slug)?.id ?? (meta.id ? existingFunctions.byId.get(meta.id)?.id : undefined);
196
263
  }
197
264
  let functionPayload = {};
198
265
  if (kind === "profile") {
@@ -230,6 +297,11 @@ async function deployFunction(
230
297
  console.error(red(`Cannot add function to workspace:\n${b(await res.text())}`));
231
298
  process.exit(1);
232
299
  } else {
300
+ // Reflect the new function in the hoisted cache so a later file in
301
+ // this same deploy that targets the same slug switches to PUT instead
302
+ // of POSTing a duplicate. Matches the pre-hoist behavior where each
303
+ // file re-fetched the list.
304
+ cacheAfterCreate(existingFunctions, id, meta.slug);
233
305
  console.log(`Function ${b(meta.name)} was successfully added to workspace ${workspace.name} with id: ${b(id)}`);
234
306
  }
235
307
  } else {
@@ -255,6 +327,10 @@ async function deployFunction(
255
327
  console.error(red(`⚠ Cannot deploy function ${b(meta.slug)}(${id}):\n${b(await res.text())}`));
256
328
  process.exit(1);
257
329
  } else {
330
+ // Slug may have been renamed by this PUT — update the slug index so a
331
+ // later file deploying under the old slug (if any) creates a new
332
+ // function instead of clobbering this one.
333
+ cacheAfterUpdate(existingFunctions, id, meta.slug);
258
334
  console.log(`${green(`✓`)} ${b(meta.name)} deployed successfully!`);
259
335
  }
260
336
  }
@@ -0,0 +1,32 @@
1
+ import { Command } from "commander";
2
+ import { red } from "../lib/chalk-code-highlight";
3
+ import { normalizeHost, readAuthFile, resolveAuth } from "../lib/auth-file";
4
+ import { fetchSpec } from "../lib/spec";
5
+ import { DEFAULT_OUTPUT, SUPPORTED_OUTPUTS, print } from "../lib/renderer";
6
+
7
+ export function buildSpecCommand(): Command {
8
+ return new Command("spec")
9
+ .description("Print the live OpenAPI spec served at /api/spec")
10
+ .option("-o, --output <format>", `Output format: ${SUPPORTED_OUTPUTS.join(", ")}`, DEFAULT_OUTPUT)
11
+ .option("-h, --host <host>", "Jitsu host (overrides ~/.jitsu/jitsu-cli.json)")
12
+ .option("-k, --apikey <api-key>", "API key (overrides ~/.jitsu/jitsu-cli.json)")
13
+ .action(async (opts: { output?: string; host?: string; apikey?: string }) => {
14
+ try {
15
+ // Pass a placeholder apikey if missing — /api/spec is public, but resolveAuth
16
+ // expects an apikey. Fall back to a synthetic one if user has no auth file.
17
+ const auth = (() => {
18
+ try {
19
+ return resolveAuth(opts);
20
+ } catch {
21
+ const host = opts.host ?? readAuthFile()?.host ?? "https://use.jitsu.com";
22
+ return { host: normalizeHost(host), apikey: "anonymous" };
23
+ }
24
+ })();
25
+ const spec = await fetchSpec(auth);
26
+ print(spec, opts.output);
27
+ } catch (e) {
28
+ console.error(red(`Error: ${e instanceof Error ? e.message : String(e)}`));
29
+ process.exit(1);
30
+ }
31
+ });
32
+ }
package/src/index.ts CHANGED
@@ -8,12 +8,27 @@ import { test } from "./commands/test";
8
8
 
9
9
  import { jitsuCliVersion, jitsuCliPackageName } from "./lib/version";
10
10
  import { whoami } from "./commands/whoami";
11
+ import { buildConfigCommand } from "./commands/config";
12
+ import { buildSpecCommand } from "./commands/spec";
13
+ import { setDefaultWorkspace, unsetDefaultWorkspace } from "./commands/default-workspace";
14
+ import { preprocessArgv } from "./lib/body-fields";
11
15
 
12
- console.log(figlet.textSync("Jitsu CLI", { horizontalLayout: "full" }));
16
+ // Pull ad-hoc body field flags (--name=val, --credentials.password=...) out of argv
17
+ // before Commander parses it. Reserved option names are left in place.
18
+ process.argv = preprocessArgv(process.argv);
19
+
20
+ // Figlet banner is decorative — write it to stderr so `jitsu config ... -o json | jq`
21
+ // works without filtering. Skip it entirely when stdout is being piped or for spec output
22
+ // (which is also typically piped to other tools).
23
+ const isPipedStdout = !process.stdout.isTTY;
24
+ if (!isPipedStdout) {
25
+ process.stderr.write(figlet.textSync("Jitsu CLI", { horizontalLayout: "full" }) + "\n");
26
+ }
13
27
 
14
28
  const p = new Command();
15
29
 
16
- p.name(jitsuCliPackageName).description("CLI command to create, test and deploy extensions for Jitsu Next");
30
+ p.name(jitsuCliPackageName).description("Jitsu CLI manage workspaces, configuration objects, and extensions");
31
+
17
32
  p.command("init")
18
33
  .description("Initialize a new Jitsu extension project")
19
34
  .arguments("[dir]")
@@ -31,19 +46,6 @@ p.command("test")
31
46
  .option("-d, --dir <dir>", "the directory of project. (Optional). By default, current directory is used")
32
47
  .action(test);
33
48
 
34
- // p.command("run")
35
- // .description("Check extensions on provided event, config and persistent storage state")
36
- // .option("-d, --dir <dir>", "the directory of project. (Optional). By default, current directory is used")
37
- // .option(
38
- // "-n, --name <name>",
39
- // "name of function file to check (optional). Required if multiple functions are defined in project"
40
- // )
41
- // .option("-t, --type <type>", "entity type to run", "function")
42
- // .requiredOption("-e, --event <file_or_json>", "path to file with event json or event json as a string")
43
- // .option("-p, --props <file_or_json>", "path to file with config json or config json as a string. (Optional)")
44
- // .option("-s, --store <file_or_json>", "path to file with state json or state json as a string. (Optional)")
45
- // .action(run);
46
-
47
49
  p.command("whoami")
48
50
  .description("Check if current user is logged in. Shows user's info if logged in")
49
51
  .option("-h, --host <host>", "Jitsu host or base url", "https://use.jitsu.com")
@@ -56,6 +58,7 @@ p.command("login")
56
58
  .option("-h, --host <host>", "Jitsu host or base url", "https://use.jitsu.com")
57
59
  .option("-k, --apikey <api-key>", "Jitsu user's Api Key. (Optional). Disables interactive login.")
58
60
  .action(login);
61
+
59
62
  p.command("logout").description("Logout").option("-f, --force", "Do not ask for confirmation").action(logout);
60
63
 
61
64
  p.command("deploy")
@@ -75,9 +78,23 @@ p.command("deploy")
75
78
  .option("-n, --name <name...>", "limit deploy to provided entities only. (Optional)")
76
79
  .action(deploy);
77
80
 
78
- //version
81
+ p.command("set-default-workspace")
82
+ .description(
83
+ "Save a default workspace to ~/.jitsu/jitsu-cli.json. Subsequent `jitsu config` commands use it when -w is omitted."
84
+ )
85
+ .argument("<id-or-slug>", "Workspace id or slug")
86
+ .option("-h, --host <host>", "Jitsu host (overrides ~/.jitsu/jitsu-cli.json)")
87
+ .option("-k, --apikey <api-key>", "API key in form keyId:secret (overrides ~/.jitsu/jitsu-cli.json)")
88
+ .action(setDefaultWorkspace);
89
+
90
+ p.command("unset-default-workspace")
91
+ .description("Remove the saved default workspace from ~/.jitsu/jitsu-cli.json")
92
+ .action(unsetDefaultWorkspace);
93
+
94
+ p.addCommand(buildConfigCommand());
95
+ p.addCommand(buildSpecCommand());
96
+
79
97
  p.version(jitsuCliPackageName + " " + jitsuCliVersion, "-v, --version");
80
- //help
81
98
  p.helpOption("--help", "display help for command");
82
99
 
83
100
  p.parse();
@@ -0,0 +1,64 @@
1
+ import { AuthInfo } from "./auth-file";
2
+
3
+ export type ApiRequest = {
4
+ method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
5
+ path: string;
6
+ query?: Record<string, string | number | boolean | undefined | null>;
7
+ body?: unknown;
8
+ };
9
+
10
+ export class ApiError extends Error {
11
+ status: number;
12
+ payload: unknown;
13
+ constructor(status: number, message: string, payload: unknown) {
14
+ super(message);
15
+ this.status = status;
16
+ this.payload = payload;
17
+ }
18
+ }
19
+
20
+ export class ApiClient {
21
+ constructor(private auth: AuthInfo) {}
22
+
23
+ url(path: string, query?: ApiRequest["query"]): string {
24
+ const base = this.auth.host;
25
+ const p = path.startsWith("/") ? path : `/${path}`;
26
+ if (!query) return `${base}${p}`;
27
+ const qs = Object.entries(query)
28
+ .filter(([, v]) => v !== undefined && v !== null && v !== "")
29
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
30
+ .join("&");
31
+ return qs ? `${base}${p}?${qs}` : `${base}${p}`;
32
+ }
33
+
34
+ async request<T = unknown>(req: ApiRequest): Promise<T> {
35
+ const method = req.method ?? "GET";
36
+ const headers: Record<string, string> = {
37
+ Accept: "application/json",
38
+ Authorization: `Bearer ${this.auth.apikey}`,
39
+ };
40
+ let body: string | undefined;
41
+ if (req.body !== undefined) {
42
+ headers["Content-Type"] = "application/json";
43
+ body = JSON.stringify(req.body);
44
+ }
45
+ const res = await fetch(this.url(req.path, req.query), { method, headers, body });
46
+ const text = await res.text();
47
+ let parsed: unknown = text;
48
+ if (text && res.headers.get("content-type")?.includes("application/json")) {
49
+ try {
50
+ parsed = JSON.parse(text);
51
+ } catch {
52
+ // fall through with raw text
53
+ }
54
+ }
55
+ if (!res.ok) {
56
+ const msg =
57
+ (parsed && typeof parsed === "object" && "message" in (parsed as any)
58
+ ? String((parsed as any).message)
59
+ : undefined) ?? `HTTP ${res.status} ${method} ${req.path}`;
60
+ throw new ApiError(res.status, msg, parsed);
61
+ }
62
+ return parsed as T;
63
+ }
64
+ }
@@ -0,0 +1,83 @@
1
+ import * as fs from "fs";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+
5
+ export type AuthInfo = { host: string; apikey: string };
6
+
7
+ export type AuthFile = {
8
+ host?: string;
9
+ apikey?: string;
10
+ defaultWorkspace?: string;
11
+ };
12
+
13
+ const DEFAULT_HOST = "https://use.jitsu.com";
14
+
15
+ export function authFilePath(): string {
16
+ return `${homedir()}/.jitsu/jitsu-cli.json`;
17
+ }
18
+
19
+ export function readAuthFile(): AuthFile | undefined {
20
+ const path = authFilePath();
21
+ if (!fs.existsSync(path)) return undefined;
22
+ return JSON.parse(readFileSync(path, { encoding: "utf-8" }));
23
+ }
24
+
25
+ // Merge `patch` into the existing auth file, creating the file (and parent dir)
26
+ // if necessary. Setting a field to `undefined` removes it.
27
+ export function updateAuthFile(patch: Partial<AuthFile>): AuthFile {
28
+ const path = authFilePath();
29
+ const existing = readAuthFile() ?? {};
30
+ const next: AuthFile = { ...existing };
31
+ for (const [k, v] of Object.entries(patch)) {
32
+ if (v === undefined) delete (next as any)[k];
33
+ else (next as any)[k] = v;
34
+ }
35
+ mkdirSync(`${homedir()}/.jitsu`, { recursive: true });
36
+ writeFileSync(path, JSON.stringify(next, null, 2));
37
+ return next;
38
+ }
39
+
40
+ export function readDefaultWorkspace(): string | undefined {
41
+ return readAuthFile()?.defaultWorkspace;
42
+ }
43
+
44
+ // Resolve host + apikey for an authenticated CLI command. Order of precedence:
45
+ // 1. --host / --apikey flags
46
+ // 2. ~/.jitsu/jitsu-cli.json
47
+ // 3. JITSU_HOST / JITSU_APIKEY env vars
48
+ // 4. https://use.jitsu.com (host only)
49
+ export function resolveAuth(opts: { host?: string; apikey?: string }): AuthInfo {
50
+ let host = opts.host;
51
+ let apikey = opts.apikey;
52
+
53
+ if (!host || !apikey) {
54
+ const file = readAuthFile();
55
+ if (file) {
56
+ if (!host) host = file.host;
57
+ if (!apikey) apikey = file.apikey;
58
+ }
59
+ }
60
+
61
+ if (!host) host = process.env.JITSU_HOST;
62
+ if (!apikey) apikey = process.env.JITSU_APIKEY;
63
+ if (!host) host = DEFAULT_HOST;
64
+
65
+ if (!apikey) {
66
+ throw new Error("Not authenticated. Run `jitsu login`, set JITSU_APIKEY, or pass --apikey <key>.");
67
+ }
68
+
69
+ return { host: normalizeHost(host), apikey };
70
+ }
71
+
72
+ export function normalizeHost(host: string): string {
73
+ let url = host;
74
+ if (!url.startsWith("http")) {
75
+ if (url.startsWith("localhost") || /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/.test(url)) {
76
+ url = "http://" + url;
77
+ } else {
78
+ url = "https://" + url;
79
+ }
80
+ }
81
+ if (url.endsWith("/")) url = url.slice(0, -1);
82
+ return url;
83
+ }
@@ -0,0 +1,68 @@
1
+ import * as fs from "fs";
2
+ import yaml from "js-yaml";
3
+ import { parseScalar, setDottedPath } from "./dotted";
4
+
5
+ export type BodySources = {
6
+ // Path to a yaml/json file. `-` means stdin.
7
+ file?: string;
8
+ // Inline JSON string (full body or partial).
9
+ json?: string;
10
+ // Already-parsed dotted-path fields, e.g. { "credentials.password": "secret" }.
11
+ fields?: Record<string, string>;
12
+ };
13
+
14
+ // Builds a single request body by merging (deep) sources in this order:
15
+ // 1. -f / --file
16
+ // 2. --json
17
+ // 3. dotted-path flags
18
+ // Later sources override earlier ones at the leaf level. Arrays are replaced, not concatenated.
19
+ // Returns undefined if no source is provided.
20
+ export function buildBody(sources: BodySources): unknown {
21
+ const layers: any[] = [];
22
+ if (sources.file) layers.push(loadFile(sources.file));
23
+ if (sources.json) layers.push(parseInlineJson(sources.json));
24
+ if (sources.fields && Object.keys(sources.fields).length > 0) {
25
+ const obj: any = {};
26
+ for (const [path, raw] of Object.entries(sources.fields)) {
27
+ setDottedPath(obj, path, parseScalar(raw));
28
+ }
29
+ layers.push(obj);
30
+ }
31
+ if (layers.length === 0) return undefined;
32
+ return layers.reduce((acc, layer) => deepMerge(acc, layer), {});
33
+ }
34
+
35
+ function loadFile(path: string): unknown {
36
+ const text = path === "-" ? fs.readFileSync(0, "utf-8") : fs.readFileSync(path, "utf-8");
37
+ // js-yaml's safeLoad handles JSON too (JSON is valid YAML). But trim first — empty file is null.
38
+ const parsed = yaml.load(text);
39
+ if (parsed === null || parsed === undefined) {
40
+ throw new Error(`File ${path} parsed as empty`);
41
+ }
42
+ return parsed;
43
+ }
44
+
45
+ function parseInlineJson(s: string): unknown {
46
+ try {
47
+ return JSON.parse(s);
48
+ } catch (e) {
49
+ throw new Error(`--json value is not valid JSON: ${(e as Error).message}`);
50
+ }
51
+ }
52
+
53
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
54
+ return typeof v === "object" && v !== null && !Array.isArray(v);
55
+ }
56
+
57
+ function deepMerge(target: any, source: any): any {
58
+ if (!isPlainObject(target) || !isPlainObject(source)) return source;
59
+ const out: any = { ...target };
60
+ for (const [k, v] of Object.entries(source)) {
61
+ if (isPlainObject(v) && isPlainObject(out[k])) {
62
+ out[k] = deepMerge(out[k], v);
63
+ } else {
64
+ out[k] = v;
65
+ }
66
+ }
67
+ return out;
68
+ }