jitsu-cli 1.10.4 → 1.11.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.
Files changed (61) hide show
  1. package/bin/jitsu +3 -0
  2. package/build.mts +39 -0
  3. package/compiled/package.json +27 -29
  4. package/compiled/src/commands/build.js +43 -55
  5. package/compiled/src/commands/config/handlers.js +292 -0
  6. package/compiled/src/commands/config/index.js +148 -0
  7. package/compiled/src/commands/config/resources.js +88 -0
  8. package/compiled/src/commands/default-workspace.js +39 -0
  9. package/compiled/src/commands/deploy.js +127 -78
  10. package/compiled/src/commands/init.js +17 -21
  11. package/compiled/src/commands/login.js +24 -27
  12. package/compiled/src/commands/shared.js +12 -17
  13. package/compiled/src/commands/spec.js +31 -0
  14. package/compiled/src/commands/test.js +7 -10
  15. package/compiled/src/commands/whoami.js +8 -12
  16. package/compiled/src/index.js +40 -24
  17. package/compiled/src/lib/api-client.js +52 -0
  18. package/compiled/src/lib/auth-file.js +67 -0
  19. package/compiled/src/lib/body-builder.js +53 -0
  20. package/compiled/src/lib/body-fields.js +47 -0
  21. package/compiled/src/lib/chalk-code-highlight.js +15 -20
  22. package/compiled/src/lib/compiled-function.js +27 -29
  23. package/compiled/src/lib/dotted.js +34 -0
  24. package/compiled/src/lib/indent.js +3 -8
  25. package/compiled/src/lib/project-config.js +20 -0
  26. package/compiled/src/lib/renderer.js +33 -0
  27. package/compiled/src/lib/spec.js +11 -0
  28. package/compiled/src/lib/template.js +7 -11
  29. package/compiled/src/lib/version.js +13 -20
  30. package/compiled/src/templates/functions.js +13 -18
  31. package/dist/main.js +45496 -86270
  32. package/dist/main.js.map +7 -1
  33. package/package.json +27 -29
  34. package/src/commands/build.ts +22 -27
  35. package/src/commands/config/handlers.ts +339 -0
  36. package/src/commands/config/index.ts +171 -0
  37. package/src/commands/config/resources.ts +110 -0
  38. package/src/commands/default-workspace.ts +44 -0
  39. package/src/commands/deploy.ts +145 -44
  40. package/src/commands/login.ts +3 -1
  41. package/src/commands/spec.ts +32 -0
  42. package/src/index.ts +36 -19
  43. package/src/lib/api-client.ts +64 -0
  44. package/src/lib/auth-file.ts +83 -0
  45. package/src/lib/body-builder.ts +68 -0
  46. package/src/lib/body-fields.ts +61 -0
  47. package/src/lib/compiled-function.ts +30 -23
  48. package/src/lib/dotted.ts +43 -0
  49. package/src/lib/project-config.ts +32 -0
  50. package/src/lib/renderer.ts +44 -0
  51. package/src/lib/spec.ts +32 -0
  52. package/tsconfig.json +2 -19
  53. package/.turbo/turbo-build.log +0 -28
  54. package/.turbo/turbo-clean.log +0 -5
  55. package/babel.config.cjs +0 -4
  56. package/dist/140.js +0 -452
  57. package/dist/140.js.map +0 -1
  58. package/dist/233.js +0 -4890
  59. package/dist/233.js.map +0 -1
  60. package/dist/445e7f36f8a19c2bf682.js +0 -900
  61. package/webpack.config.js +0 -49
package/package.json CHANGED
@@ -1,38 +1,37 @@
1
1
  {
2
2
  "name": "jitsu-cli",
3
- "version": "1.10.4",
3
+ "version": "1.11.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/jitsucom/jitsu",
7
+ "directory": "cli/jitsu-cli"
8
+ },
4
9
  "description": "",
5
10
  "author": "Jitsu Dev Team <dev@jitsu.com>",
6
11
  "publishConfig": {
7
12
  "access": "public"
8
13
  },
9
- "bin": "./bin/jitsu-cli",
14
+ "bin": {
15
+ "jitsu-cli": "./bin/jitsu-cli",
16
+ "jitsu": "./bin/jitsu"
17
+ },
10
18
  "license": "MIT",
11
19
  "private": false,
12
20
  "dependencies": {
21
+ "esbuild": "^0.28.1",
13
22
  "figlet": "^1.6.0",
14
23
  "jest-cli": "^29.7.0",
15
24
  "tslib": "^2.6.3",
16
25
  "typescript": "^5.6.3"
17
26
  },
18
27
  "devDependencies": {
19
- "@babel/preset-env": "^7.23.2",
20
- "@babel/preset-typescript": "^7.23.2",
21
- "@rollup/plugin-commonjs": "^28.0.2",
22
- "@rollup/plugin-json": "^6.0.0",
23
- "@rollup/plugin-node-resolve": "^16.0.0",
24
- "@rollup/plugin-terser": "^0.4.3",
25
- "@rollup/plugin-typescript": "^11.1.3",
26
28
  "@types/chalk": "^2.2.0",
27
29
  "@types/commander": "^2.12.2",
28
30
  "@types/express": "^4.17.21",
29
31
  "@types/inquirer": "^9.0.3",
30
- "@types/jest": "^29.5.5",
31
- "@types/lodash": "^4.14.198",
32
+ "@types/lodash": "^4.17.24",
32
33
  "@types/node": "^18.15.3",
33
- "@types/webpack": "^5.28.5",
34
- "@webpack-cli/generators": "^3.0.7",
35
- "babel-loader": "^9.1.3",
34
+ "@vitest/ui": "^4.1.4",
36
35
  "chalk": "^5.3.0",
37
36
  "commander": "^11.0.0",
38
37
  "cross-fetch": "^4.0.0",
@@ -40,26 +39,25 @@
40
39
  "etag": "^1.8.1",
41
40
  "inquirer": "^9.2.11",
42
41
  "jest": "^29.7.0",
42
+ "js-yaml": "^4.2.0",
43
+ "@types/js-yaml": "^4.0.9",
43
44
  "json5": "^2.2.3",
44
- "lodash": "^4.17.21",
45
- "node-fetch": "^3.3.2",
46
- "node-loader": "^2.0.0",
45
+ "lodash": "^4.18.1",
47
46
  "prismjs": "^1.30.0",
48
- "rollup": "^3.29.5",
49
47
  "semver": "^7.5.4",
50
- "ts-jest": "^29.1.1",
51
- "ts-loader": "^9.5.1",
52
- "ts-node": "^10.9.2",
53
- "webpack": "^5.99.5",
54
- "webpack-cli": "^6.0.1",
55
- "@jitsu/functions-lib": "1.10.4",
56
- "juava": "1.10.4",
57
- "@jitsu/protocols": "1.10.4"
48
+ "tsx": "^4.21.0",
49
+ "vitest": "^4.1.4",
50
+ "vite": "^6.4.3",
51
+ "@jitsu/common-config": "0.0.0",
52
+ "@jitsu/protocols": "0.0.0",
53
+ "@jitsu/functions-lib": "0.0.0",
54
+ "juava": "0.0.0"
58
55
  },
59
56
  "scripts": {
60
- "clean": "rm -rf ./dist",
61
- "compile": "tsc -p . ",
62
- "build": "pnpm compile && webpack",
57
+ "clean": "rm -rf ./dist ./compiled",
58
+ "typecheck": "tsc --noEmit",
59
+ "build": "tsc && tsx build.mts",
60
+ "cli": "tsx src/index.ts",
63
61
  "run": "pnpm build && node dist/main.js",
64
62
  "login": "pnpm build && node dist/main.js login",
65
63
  "init child": "pnpm build && node dist/main.js init",
@@ -1,10 +1,6 @@
1
1
  import path from "path";
2
- import { mkdirSync, readdirSync, writeFileSync, existsSync, lstatSync } from "fs";
3
- import typescript from "@rollup/plugin-typescript";
4
- import resolve from "@rollup/plugin-node-resolve";
5
- import commonjs from "@rollup/plugin-commonjs";
6
- import rollupJson from "@rollup/plugin-json";
7
- import { ModuleFormat, rollup } from "rollup";
2
+ import { mkdirSync, readdirSync, existsSync, lstatSync } from "fs";
3
+ import * as esbuild from "esbuild";
8
4
  import { exec } from "child_process";
9
5
  import { loadPackageJson } from "./shared";
10
6
  import { b, green, red } from "../lib/chalk-code-highlight";
@@ -89,32 +85,31 @@ async function buildFile(projectDir: string, dir: string, fileName: string) {
89
85
  const funcFile = path.resolve(projectDir, "src", path.join(dir, fileName));
90
86
  process.chdir(projectDir);
91
87
 
92
- const rollupPlugins = [
93
- typescript(),
94
- resolve({ preferBuiltins: false }),
95
- commonjs(),
96
- rollupJson(),
97
- // terser(),
98
- ];
88
+ mkdirSync(path.resolve(projectDir, "dist/" + dir), { recursive: true });
89
+ const compiledFunctionPath = `dist/${dir}/${fileName.replace(".ts", ".js")}`;
99
90
 
100
- const bundle = await rollup({
101
- input: [funcFile],
102
- plugins: rollupPlugins,
91
+ await esbuild.build({
92
+ entryPoints: [funcFile],
93
+ absWorkingDir: projectDir,
94
+ bundle: true,
95
+ platform: "neutral",
96
+ format: "esm",
97
+ outfile: path.resolve(projectDir, compiledFunctionPath),
103
98
  external: ["@jitsu/functions-lib"],
104
99
  logLevel: "silent",
100
+ loader: {
101
+ ".json": "json",
102
+ },
103
+ // Resolve dependencies from the user's project node_modules
104
+ nodePaths: [path.resolve(projectDir, "node_modules")],
105
105
  });
106
106
 
107
- let format: ModuleFormat = "es";
108
- let output = await bundle.generate({
109
- dir: projectDir,
110
- format: format,
111
- });
112
-
113
- mkdirSync(path.resolve(projectDir, "dist/" + dir), { recursive: true });
114
- const compiledFunctionPath = `dist/${dir}/${fileName.replace(".ts", ".js")}`;
115
- writeFileSync(path.resolve(projectDir, compiledFunctionPath), output.output[0].code);
116
107
  //to verify that function is readable
117
- const compiledFunction = await getFunctionFromFilePath(path.resolve(projectDir, compiledFunctionPath), "function");
108
+ const compiledFunction = await getFunctionFromFilePath(
109
+ projectDir,
110
+ path.resolve(projectDir, compiledFunctionPath),
111
+ "function"
112
+ );
118
113
  console.log(
119
114
  [`${green(`✓`)} Function ${b(fileName)} compiled successfully`, ` slug = ${b(compiledFunction.meta.slug)}`]
120
115
  .filter(Boolean)
@@ -128,7 +123,6 @@ function checkTypescript(projectDir: string): string[] | void {
128
123
  console.info(`No ${b("tsconfig.json")} file found in ${b(projectDir)}. Assuming JavaScript project`);
129
124
  return;
130
125
  }
131
- console.log(`Checking TypeScript files in ${b(projectDir)}`);
132
126
  let compilerOptions: ts.CompilerOptions = {};
133
127
  let filenames: string[] = [];
134
128
  const tsconfig = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
@@ -137,6 +131,7 @@ function checkTypescript(projectDir: string): string[] | void {
137
131
  typeRoots: [path.resolve(projectDir, "node_modules", "@types")],
138
132
  checkJs: true,
139
133
  allowJs: true,
134
+ skipLibCheck: true,
140
135
  noEmit: true,
141
136
  esModuleInterop: typeof compilerOptions.esModuleInterop !== "undefined" ? compilerOptions.esModuleInterop : true,
142
137
  moduleResolution:
@@ -0,0 +1,339 @@
1
+ import { ApiClient, ApiError } from "../../lib/api-client";
2
+ import { readDefaultWorkspace, resolveAuth } from "../../lib/auth-file";
3
+ import { buildBody } from "../../lib/body-builder";
4
+ import { consumeBodyFields } from "../../lib/body-fields";
5
+ import { print } from "../../lib/renderer";
6
+ import { Resource } from "./resources";
7
+
8
+ // `/api/{workspaceId}/config/...` endpoints take an ID — passing a slug returns empty
9
+ // silently. Resolve via `/api/workspace/{idOrSlug}` first so `-w canalrcn` works and
10
+ // `-w nonexistent-slug` errors out clearly instead of returning [].
11
+ async function resolveWorkspaceId(client: ApiClient, idOrSlug: string): Promise<string> {
12
+ // Always go through the resolution endpoint. The server-side slug validator rejects
13
+ // slugs that collide with any existing workspace id, so an id and a slug can never
14
+ // map to different workspaces here.
15
+ try {
16
+ const ws = await client.request<{ id: string }>({
17
+ method: "GET",
18
+ path: `/api/workspace/${encodeURIComponent(idOrSlug)}`,
19
+ });
20
+ return ws.id;
21
+ } catch (e) {
22
+ if (e instanceof ApiError && e.status === 404) {
23
+ throw new Error(`Workspace '${idOrSlug}' not found (or you don't have access)`);
24
+ }
25
+ throw e;
26
+ }
27
+ }
28
+
29
+ // Common shape of leaf-command options (those known to Commander).
30
+ export type LeafOpts = {
31
+ workspace?: string;
32
+ output?: string;
33
+ host?: string;
34
+ apikey?: string;
35
+ file?: string;
36
+ json?: string;
37
+ cascade?: boolean;
38
+ strict?: boolean;
39
+ from?: string;
40
+ to?: string;
41
+ };
42
+
43
+ // Combines body sources: -f file, --json inline, and ad-hoc field flags pre-extracted
44
+ // from argv (e.g. --credentials.password=secret, --destinationType=postgres).
45
+ function gatherBody(opts: LeafOpts) {
46
+ const fields = consumeBodyFields();
47
+ return buildBody({ file: opts.file, json: opts.json, fields });
48
+ }
49
+
50
+ function requireWorkspace(opts: LeafOpts): string {
51
+ if (opts.workspace) return opts.workspace;
52
+ const fallback = readDefaultWorkspace();
53
+ if (fallback) return fallback;
54
+ throw new Error("--workspace / -w is required (or set a default via `jitsu set-default-workspace <id-or-slug>`)");
55
+ }
56
+
57
+ async function requireResolvedWorkspaceId(opts: LeafOpts, client: ApiClient): Promise<string> {
58
+ return resolveWorkspaceId(client, requireWorkspace(opts));
59
+ }
60
+
61
+ // ----------------- list -----------------
62
+
63
+ export async function runList(resource: Resource, opts: LeafOpts) {
64
+ const auth = resolveAuth(opts);
65
+ const client = new ApiClient(auth);
66
+
67
+ switch (resource.kind) {
68
+ case "workspace": {
69
+ const data = await client.request({ method: "GET", path: "/api/workspace" });
70
+ print(data, opts.output);
71
+ return;
72
+ }
73
+ case "configObject": {
74
+ const ws = await requireResolvedWorkspaceId(opts, client);
75
+ const data = await client.request<{ objects: unknown[] }>({
76
+ method: "GET",
77
+ path: `/api/${encodeURIComponent(ws)}/config/${resource.type}`,
78
+ });
79
+ // Server returns { objects: [...] } — print the array directly so YAML/JSON
80
+ // are list-shaped and easier to pipe into other tools.
81
+ print(data.objects ?? data, opts.output);
82
+ return;
83
+ }
84
+ case "link": {
85
+ const ws = await requireResolvedWorkspaceId(opts, client);
86
+ const data = await client.request<{ links: unknown[] }>({
87
+ method: "GET",
88
+ path: `/api/${encodeURIComponent(ws)}/config/link`,
89
+ });
90
+ print(data.links ?? data, opts.output);
91
+ return;
92
+ }
93
+ case "profile-builder": {
94
+ const ws = await requireResolvedWorkspaceId(opts, client);
95
+ const data = await client.request<{ profileBuilders: unknown[] }>({
96
+ method: "GET",
97
+ path: `/api/${encodeURIComponent(ws)}/config/profile-builder`,
98
+ });
99
+ print(data.profileBuilders ?? data, opts.output);
100
+ return;
101
+ }
102
+ }
103
+ }
104
+
105
+ // ----------------- get -----------------
106
+
107
+ export async function runGet(resource: Resource, id: string, opts: LeafOpts) {
108
+ const auth = resolveAuth(opts);
109
+ const client = new ApiClient(auth);
110
+
111
+ switch (resource.kind) {
112
+ case "workspace": {
113
+ // /api/workspace/{idOrSlug} accepts either; no extra resolution needed.
114
+ const data = await client.request({ method: "GET", path: `/api/workspace/${encodeURIComponent(id)}` });
115
+ print(data, opts.output);
116
+ return;
117
+ }
118
+ case "configObject": {
119
+ const ws = await requireResolvedWorkspaceId(opts, client);
120
+ const data = await client.request({
121
+ method: "GET",
122
+ path: `/api/${encodeURIComponent(ws)}/config/${resource.type}/${encodeURIComponent(id)}`,
123
+ });
124
+ print(data, opts.output);
125
+ return;
126
+ }
127
+ default:
128
+ throw new Error(`get is not supported for ${resource.noun}`);
129
+ }
130
+ }
131
+
132
+ // ----------------- create -----------------
133
+
134
+ export async function runCreate(resource: Resource, opts: LeafOpts) {
135
+ const auth = resolveAuth(opts);
136
+ const client = new ApiClient(auth);
137
+ const body = gatherBody(opts) ?? {};
138
+
139
+ switch (resource.kind) {
140
+ case "workspace": {
141
+ const data = await client.request({ method: "POST", path: "/api/workspace", body });
142
+ print(data, opts.output);
143
+ return;
144
+ }
145
+ case "configObject": {
146
+ const ws = await requireResolvedWorkspaceId(opts, client);
147
+ const data = await client.request({
148
+ method: "POST",
149
+ path: `/api/${encodeURIComponent(ws)}/config/${resource.type}`,
150
+ body,
151
+ });
152
+ print(data, opts.output);
153
+ return;
154
+ }
155
+ case "link": {
156
+ const ws = await requireResolvedWorkspaceId(opts, client);
157
+ const data = await client.request({
158
+ method: "POST",
159
+ path: `/api/${encodeURIComponent(ws)}/config/link`,
160
+ body,
161
+ });
162
+ print(data, opts.output);
163
+ return;
164
+ }
165
+ case "profile-builder": {
166
+ const ws = await requireResolvedWorkspaceId(opts, client);
167
+ const data = await client.request({
168
+ method: "POST",
169
+ path: `/api/${encodeURIComponent(ws)}/config/profile-builder`,
170
+ body,
171
+ });
172
+ print(data, opts.output);
173
+ return;
174
+ }
175
+ }
176
+ }
177
+
178
+ // ----------------- update -----------------
179
+
180
+ export async function runUpdate(resource: Resource, id: string | undefined, opts: LeafOpts) {
181
+ const auth = resolveAuth(opts);
182
+ const client = new ApiClient(auth);
183
+ const body = gatherBody(opts);
184
+ if (body === undefined) {
185
+ throw new Error("update requires at least one of -f, --json, or --field.path=value flags");
186
+ }
187
+
188
+ switch (resource.kind) {
189
+ case "workspace": {
190
+ if (!id) throw new Error("workspace id or slug is required");
191
+ const data = await client.request({
192
+ method: "PUT",
193
+ path: `/api/workspace/${encodeURIComponent(id)}`,
194
+ body,
195
+ });
196
+ print(data, opts.output);
197
+ return;
198
+ }
199
+ case "configObject": {
200
+ const ws = await requireResolvedWorkspaceId(opts, client);
201
+ if (!id) throw new Error("id is required");
202
+ const data = await client.request({
203
+ method: "PUT",
204
+ path: `/api/${encodeURIComponent(ws)}/config/${resource.type}/${encodeURIComponent(id)}`,
205
+ body,
206
+ });
207
+ print(data ?? { ok: true }, opts.output);
208
+ return;
209
+ }
210
+ case "link": {
211
+ // Link upsert — server identifies via id (or fromId+toId) in the body.
212
+ // If the user passed a positional id, fold it into body.id so it actually reaches the API.
213
+ const ws = await requireResolvedWorkspaceId(opts, client);
214
+ const finalBody = mergePositionalId(body, id, "id");
215
+ const data = await client.request({
216
+ method: "PUT",
217
+ path: `/api/${encodeURIComponent(ws)}/config/link`,
218
+ body: finalBody,
219
+ });
220
+ print(data, opts.output);
221
+ return;
222
+ }
223
+ case "profile-builder": {
224
+ // Upsert keyed off body.profileBuilder.id; without it the API creates a new builder
225
+ // even though the CLI signature requires <id>. Inject the positional id.
226
+ const ws = await requireResolvedWorkspaceId(opts, client);
227
+ if (!id) throw new Error("id is required");
228
+ const finalBody = mergeNestedPositionalId(body, id, "profileBuilder", "id");
229
+ const data = await client.request({
230
+ method: "PUT",
231
+ path: `/api/${encodeURIComponent(ws)}/config/profile-builder`,
232
+ body: finalBody,
233
+ });
234
+ print(data, opts.output);
235
+ return;
236
+ }
237
+ }
238
+ }
239
+
240
+ // Set `body[key] = id` when id is given. Throws if body[key] is already set to a different value.
241
+ function mergePositionalId(body: any, id: string | undefined, key: string): any {
242
+ if (id === undefined) return body;
243
+ if (body && typeof body === "object" && body[key] !== undefined && body[key] !== id) {
244
+ throw new Error(`positional id '${id}' conflicts with body.${key} '${body[key]}'`);
245
+ }
246
+ return { ...(body && typeof body === "object" ? body : {}), [key]: id };
247
+ }
248
+
249
+ // Same idea, one level deep: set `body[outer][inner] = id`.
250
+ function mergeNestedPositionalId(body: any, id: string, outer: string, inner: string): any {
251
+ const base = body && typeof body === "object" ? body : {};
252
+ const nested = base[outer] && typeof base[outer] === "object" ? base[outer] : {};
253
+ if (nested[inner] !== undefined && nested[inner] !== id) {
254
+ throw new Error(`positional id '${id}' conflicts with body.${outer}.${inner} '${nested[inner]}'`);
255
+ }
256
+ return { ...base, [outer]: { ...nested, [inner]: id } };
257
+ }
258
+
259
+ // ----------------- delete -----------------
260
+
261
+ export async function runDelete(resource: Resource, id: string | undefined, opts: LeafOpts) {
262
+ const auth = resolveAuth(opts);
263
+ const client = new ApiClient(auth);
264
+
265
+ switch (resource.kind) {
266
+ case "workspace": {
267
+ if (!id) throw new Error("workspace id is required");
268
+ const data = await client.request({
269
+ method: "DELETE",
270
+ path: "/api/workspace",
271
+ body: { workspaceId: id },
272
+ });
273
+ print(data, opts.output);
274
+ return;
275
+ }
276
+ case "configObject": {
277
+ const ws = await requireResolvedWorkspaceId(opts, client);
278
+ if (!id) throw new Error("id is required");
279
+ const query: Record<string, string> = {};
280
+ if (opts.cascade) query.cascade = "true";
281
+ if (opts.strict) query.strict = "true";
282
+ const data = await client.request({
283
+ method: "DELETE",
284
+ path: `/api/${encodeURIComponent(ws)}/config/${resource.type}/${encodeURIComponent(id)}`,
285
+ query,
286
+ });
287
+ print(data ?? { ok: true }, opts.output);
288
+ return;
289
+ }
290
+ case "link": {
291
+ const ws = await requireResolvedWorkspaceId(opts, client);
292
+ const query: Record<string, string> = {};
293
+ if (id) {
294
+ query.id = id;
295
+ } else if (opts.from && opts.to) {
296
+ query.fromId = opts.from;
297
+ query.toId = opts.to;
298
+ } else {
299
+ throw new Error("connection delete requires either <id> or both --from and --to");
300
+ }
301
+ const data = await client.request({
302
+ method: "DELETE",
303
+ path: `/api/${encodeURIComponent(ws)}/config/link`,
304
+ query,
305
+ });
306
+ print(data ?? { ok: true }, opts.output);
307
+ return;
308
+ }
309
+ case "profile-builder": {
310
+ const ws = await requireResolvedWorkspaceId(opts, client);
311
+ if (!id) throw new Error("id is required");
312
+ const data = await client.request({
313
+ method: "DELETE",
314
+ path: `/api/${encodeURIComponent(ws)}/config/profile-builder`,
315
+ query: { id },
316
+ });
317
+ print(data ?? { ok: true }, opts.output);
318
+ return;
319
+ }
320
+ }
321
+ }
322
+
323
+ // ----------------- test (configObject only) -----------------
324
+
325
+ export async function runTest(resource: Resource, opts: LeafOpts) {
326
+ if (resource.kind !== "configObject" || !resource.supportsTest) {
327
+ throw new Error(`test is not supported for ${resource.noun}`);
328
+ }
329
+ const auth = resolveAuth(opts);
330
+ const client = new ApiClient(auth);
331
+ const ws = await requireResolvedWorkspaceId(opts, client);
332
+ const body = gatherBody(opts) ?? {};
333
+ const data = await client.request({
334
+ method: "POST",
335
+ path: `/api/${encodeURIComponent(ws)}/config/${resource.type}/test`,
336
+ body,
337
+ });
338
+ print(data, opts.output);
339
+ }
@@ -0,0 +1,171 @@
1
+ import { Command } from "commander";
2
+ import { red } from "../../lib/chalk-code-highlight";
3
+ import { DEFAULT_OUTPUT, SUPPORTED_OUTPUTS } from "../../lib/renderer";
4
+ import { resources, Resource, Verb, verbsFor } from "./resources";
5
+ import { LeafOpts, runCreate, runDelete, runGet, runList, runTest, runUpdate } from "./handlers";
6
+
7
+ // Decorate a Command with the common options every leaf shares.
8
+ function withCommonOpts(cmd: Command): Command {
9
+ return cmd
10
+ .option("-w, --workspace <id-or-slug>", "Target workspace id or slug")
11
+ .option("-o, --output <format>", `Output format: ${SUPPORTED_OUTPUTS.join(", ")}`, DEFAULT_OUTPUT)
12
+ .option("-h, --host <host>", "Jitsu host (overrides ~/.jitsu/jitsu-cli.json)")
13
+ .option("-k, --apikey <api-key>", "API key in form keyId:secret (overrides ~/.jitsu/jitsu-cli.json)");
14
+ }
15
+
16
+ function withBodyOpts(cmd: Command): Command {
17
+ return cmd
18
+ .option("-f, --file <path>", "Read body from yaml or json file (use `-` for stdin)")
19
+ .option("--json <json>", "Inline JSON body")
20
+ .addHelpText(
21
+ "after",
22
+ [
23
+ "",
24
+ "Body fields can also be set ad-hoc via --<path>=<value> flags.",
25
+ "Examples:",
26
+ " --name=my-destination",
27
+ " --destinationType=postgres",
28
+ " --credentials.host=db.example.com",
29
+ " --credentials.password=secret",
30
+ ' --credentials.keys=\'["a","b"]\'',
31
+ 'Values starting with [, {, " or matching number/boolean/null are parsed as JSON;',
32
+ "everything else is a plain string. -f, --json, and --field flags merge in that order.",
33
+ ].join("\n")
34
+ );
35
+ }
36
+
37
+ // Wrap an async handler so any thrown error prints a red message and sets exit 1.
38
+ function action<T extends any[]>(fn: (...args: T) => Promise<void> | void) {
39
+ return async (...args: T) => {
40
+ try {
41
+ await fn(...args);
42
+ } catch (e) {
43
+ const msg = e instanceof Error ? e.message : String(e);
44
+ console.error(red(`Error: ${msg}`));
45
+ process.exit(1);
46
+ }
47
+ };
48
+ }
49
+
50
+ // Builds a leaf command for a (resource, verb) pair. The same builder is used in both
51
+ // orderings (`config <noun> <verb>` and `config <verb> <noun>`) so the surface stays
52
+ // consistent; only the parent.
53
+ function buildLeaf(resource: Resource, verb: Verb): Command {
54
+ switch (verb) {
55
+ case "list": {
56
+ const cmd = withCommonOpts(new Command("list"))
57
+ .description(`List ${resource.noun}`)
58
+ .action(action(async (opts: LeafOpts) => runList(resource, opts)));
59
+ return cmd;
60
+ }
61
+ case "get": {
62
+ const idLabel = resource.kind === "workspace" ? "<id-or-slug>" : "<id>";
63
+ const cmd = withCommonOpts(new Command("get"))
64
+ .description(`Get a single ${singular(resource)} by id`)
65
+ .argument(idLabel, `Identifier of the ${singular(resource)}`)
66
+ .action(action(async (id: string, opts: LeafOpts) => runGet(resource, id, opts)));
67
+ return cmd;
68
+ }
69
+ case "create": {
70
+ const cmd = withBodyOpts(withCommonOpts(new Command("create")))
71
+ .description(`Create a ${singular(resource)}`)
72
+ .action(action(async (opts: LeafOpts) => runCreate(resource, opts)));
73
+ return cmd;
74
+ }
75
+ case "update": {
76
+ const idLabel = resource.kind === "link" ? "[id]" : "<id>";
77
+ const idDesc =
78
+ resource.kind === "link"
79
+ ? `Link id (optional — connections are upserts identified by fromId+toId)`
80
+ : `Identifier of the ${singular(resource)}`;
81
+ const cmd = withBodyOpts(withCommonOpts(new Command("update")))
82
+ .description(`Update a ${singular(resource)} (deep-merge into existing)`)
83
+ .argument(idLabel, idDesc)
84
+ .action(action(async (id: string | undefined, opts: LeafOpts) => runUpdate(resource, id, opts)));
85
+ return cmd;
86
+ }
87
+ case "delete": {
88
+ const idLabel = resource.kind === "link" ? "[id]" : "<id>";
89
+ let cmd = withCommonOpts(new Command("delete"))
90
+ .alias("rm")
91
+ .description(`Delete a ${singular(resource)}`)
92
+ .argument(idLabel, `Identifier of the ${singular(resource)}`);
93
+ if (resource.kind === "configObject") {
94
+ cmd = cmd
95
+ .option("--cascade", "Also delete linked connections that reference this object")
96
+ .option("--strict", "Refuse to delete if linked connections exist");
97
+ }
98
+ if (resource.kind === "link") {
99
+ cmd = cmd
100
+ .option("--from <streamOrServiceId>", "Source id (use with --to instead of <id>)")
101
+ .option("--to <destinationId>", "Destination id (use with --from instead of <id>)");
102
+ }
103
+ cmd = cmd.action(action(async (id: string | undefined, opts: LeafOpts) => runDelete(resource, id, opts)));
104
+ return cmd;
105
+ }
106
+ case "test": {
107
+ const cmd = withBodyOpts(withCommonOpts(new Command("test")))
108
+ .description(`Test connectivity for a ${singular(resource)} configuration`)
109
+ .action(action(async (opts: LeafOpts) => runTest(resource, opts)));
110
+ return cmd;
111
+ }
112
+ }
113
+ }
114
+
115
+ function singular(r: Resource): string {
116
+ return r.aliases[0] ?? r.noun.replace(/s$/, "");
117
+ }
118
+
119
+ export function buildConfigCommand(): Command {
120
+ const config = new Command("config")
121
+ .description("Manage workspace configuration objects (destinations, streams, connections, ...)")
122
+ .addHelpText(
123
+ "after",
124
+ [
125
+ "",
126
+ "Two equivalent invocation styles are supported:",
127
+ " jitsu config <noun> <verb> [args] e.g. jitsu config destinations list -w ws",
128
+ " jitsu config <verb> <noun> [args] e.g. jitsu config list destinations -w ws",
129
+ "",
130
+ "Resources:",
131
+ ...resources.map(r => ` ${r.noun.padEnd(20)} ${r.description}`),
132
+ ].join("\n")
133
+ );
134
+
135
+ // Tree A: noun-first. `config <noun> <verb>`
136
+ for (const resource of resources) {
137
+ const nounCmd = new Command(resource.noun).description(resource.description);
138
+ for (const alias of resource.aliases) nounCmd.alias(alias);
139
+ for (const verb of verbsFor(resource.kind)) {
140
+ nounCmd.addCommand(buildLeaf(resource, verb));
141
+ }
142
+ if (resource.supportsTest) {
143
+ nounCmd.addCommand(buildLeaf(resource, "test"));
144
+ }
145
+ config.addCommand(nounCmd);
146
+ }
147
+
148
+ // Tree B: verb-first. `config <verb> <noun>`
149
+ const allVerbs: Verb[] = ["list", "get", "create", "update", "delete", "test"];
150
+ for (const verb of allVerbs) {
151
+ const verbCmd = new Command(verb).description(
152
+ `${capitalize(verb)} a configuration object (alias for the noun-first form)`
153
+ );
154
+ for (const resource of resources) {
155
+ const applicable = verbsFor(resource.kind).includes(verb) || (verb === "test" && resource.supportsTest);
156
+ if (!applicable) continue;
157
+ const leaf = buildLeaf(resource, verb);
158
+ // Override the leaf's name from the verb to the noun for this tree.
159
+ leaf.name(resource.noun);
160
+ for (const alias of resource.aliases) leaf.alias(alias);
161
+ verbCmd.addCommand(leaf);
162
+ }
163
+ config.addCommand(verbCmd);
164
+ }
165
+
166
+ return config;
167
+ }
168
+
169
+ function capitalize(s: string): string {
170
+ return s.charAt(0).toUpperCase() + s.slice(1);
171
+ }