jitsu-cli 2.14.0-beta.77 → 2.14.0-beta.85

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.
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,61 @@
1
+ // Pre-extracts body field flags from process.argv before Commander parses it.
2
+ //
3
+ // Why: we want users to write `--credentials.password=secret` and `--destinationType=postgres`
4
+ // as ad-hoc body fields, but Commander would reject unknown options. The simplest robust
5
+ // solution is to filter argv: anything matching `--<name>=<value>` whose head segment is NOT
6
+ // a reserved Commander option is captured here, and the rest is left for Commander.
7
+ //
8
+ // Single global stash is fine because the CLI runs one command per process.
9
+
10
+ // Reserved option names — any `--name=value` whose head is in this set is left for Commander.
11
+ // Everything else becomes a body field (top-level for flat names, nested for dotted ones).
12
+ const RESERVED = new Set([
13
+ "workspace",
14
+ "output",
15
+ "host",
16
+ "apikey",
17
+ "file",
18
+ "json",
19
+ "cascade",
20
+ "strict",
21
+ "from",
22
+ "to",
23
+ "help",
24
+ "version",
25
+ ]);
26
+
27
+ const fields: Record<string, string> = {};
28
+
29
+ // Activate body-field extraction only for the `config` command group. Otherwise the
30
+ // preprocessor would steal flags from `deploy --type=function --name=foo` etc.
31
+ function shouldExtract(argv: string[]): boolean {
32
+ for (let i = 2; i < argv.length; i++) {
33
+ const a = argv[i];
34
+ if (a.startsWith("-")) continue;
35
+ return a === "config";
36
+ }
37
+ return false;
38
+ }
39
+
40
+ export function preprocessArgv(argv: string[]): string[] {
41
+ if (!shouldExtract(argv)) return argv;
42
+ const out: string[] = [];
43
+ for (const arg of argv) {
44
+ const m = /^--([a-zA-Z][\w.-]*)=([\s\S]*)$/.exec(arg);
45
+ if (m) {
46
+ const head = m[1].split(".")[0];
47
+ if (!RESERVED.has(head)) {
48
+ fields[m[1]] = m[2];
49
+ continue;
50
+ }
51
+ }
52
+ out.push(arg);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ export function consumeBodyFields(): Record<string, string> {
58
+ const copy = { ...fields };
59
+ for (const k of Object.keys(fields)) delete fields[k];
60
+ return copy;
61
+ }
@@ -0,0 +1,43 @@
1
+ // Parse a value string from a dotted-path flag.
2
+ // Heuristic: if it parses cleanly as JSON and starts with `[`, `{`, `"`, or is a
3
+ // number/boolean/null literal, take the JSON value. Otherwise treat as a plain string.
4
+ // This lets users pass arrays/objects without quoting hell while keeping bare strings
5
+ // (`--name=foo`) working without quoting them as `"foo"`.
6
+ export function parseScalar(raw: string): unknown {
7
+ if (raw === "") return "";
8
+ const trimmed = raw.trim();
9
+ const first = trimmed[0];
10
+ const looksLikeJson =
11
+ first === "{" ||
12
+ first === "[" ||
13
+ first === '"' ||
14
+ trimmed === "true" ||
15
+ trimmed === "false" ||
16
+ trimmed === "null" ||
17
+ /^-?\d/.test(trimmed);
18
+ if (looksLikeJson) {
19
+ try {
20
+ return JSON.parse(trimmed);
21
+ } catch {
22
+ // fall through to string
23
+ }
24
+ }
25
+ return raw;
26
+ }
27
+
28
+ // Build an object by setting `path` (e.g. "credentials.password") to `value`.
29
+ // Numeric path segments (`a.0.b`) are NOT special — kept as object keys. If you
30
+ // need arrays, pass them as JSON values (e.g. --keys='["a","b"]').
31
+ export function setDottedPath(target: any, path: string, value: unknown): any {
32
+ const parts = path.split(".");
33
+ let node = target;
34
+ for (let i = 0; i < parts.length - 1; i++) {
35
+ const key = parts[i];
36
+ if (node[key] == null || typeof node[key] !== "object" || Array.isArray(node[key])) {
37
+ node[key] = {};
38
+ }
39
+ node = node[key];
40
+ }
41
+ node[parts[parts.length - 1]] = value;
42
+ return target;
43
+ }
@@ -0,0 +1,44 @@
1
+ import yaml from "js-yaml";
2
+
3
+ export interface Renderer {
4
+ format: string;
5
+ render(value: unknown): string;
6
+ }
7
+
8
+ const renderers: Record<string, Renderer> = {
9
+ yaml: {
10
+ format: "yaml",
11
+ render(value) {
12
+ if (value === undefined) return "";
13
+ // skipInvalid drops things like undefined/functions silently.
14
+ // noRefs avoids YAML anchors (`&id001`) which confuse readers and most YAML consumers.
15
+ return yaml.dump(value, { skipInvalid: true, noRefs: true, sortKeys: false, lineWidth: 120 });
16
+ },
17
+ },
18
+ json: {
19
+ format: "json",
20
+ render(value) {
21
+ return JSON.stringify(value, null, 2) + "\n";
22
+ },
23
+ },
24
+ };
25
+
26
+ export const SUPPORTED_OUTPUTS = Object.keys(renderers);
27
+ export const DEFAULT_OUTPUT = "yaml";
28
+
29
+ export function getRenderer(format?: string): Renderer {
30
+ const key = (format ?? DEFAULT_OUTPUT).toLowerCase();
31
+ const r = renderers[key];
32
+ if (!r) {
33
+ throw new Error(`Unsupported output format '${format}'. Supported: ${SUPPORTED_OUTPUTS.join(", ")}`);
34
+ }
35
+ return r;
36
+ }
37
+
38
+ export function registerRenderer(r: Renderer) {
39
+ renderers[r.format.toLowerCase()] = r;
40
+ }
41
+
42
+ export function print(value: unknown, format?: string) {
43
+ process.stdout.write(getRenderer(format).render(value));
44
+ }
@@ -0,0 +1,32 @@
1
+ import { ApiClient } from "./api-client";
2
+ import { AuthInfo } from "./auth-file";
3
+
4
+ // Minimal subset of OpenAPI 3 we use. Avoids a runtime dep on openapi-types.
5
+ export type OpenApiSpec = {
6
+ openapi?: string;
7
+ info?: { title?: string; version?: string; description?: string };
8
+ paths?: Record<string, Record<string, OpenApiOperation>>;
9
+ components?: { schemas?: Record<string, any> };
10
+ };
11
+ export type OpenApiOperation = {
12
+ summary?: string;
13
+ description?: string;
14
+ tags?: string[];
15
+ parameters?: any[];
16
+ requestBody?: any;
17
+ responses?: Record<string, any>;
18
+ };
19
+
20
+ export async function fetchSpec(auth: AuthInfo): Promise<OpenApiSpec> {
21
+ // /api/spec is public — no auth required, but reusing the client keeps host handling consistent.
22
+ const client = new ApiClient(auth);
23
+ return client.request<OpenApiSpec>({ method: "GET", path: "/api/spec" });
24
+ }
25
+
26
+ // Resolves the operation that matches a (templated) path + method, e.g.
27
+ // ("/api/{workspaceId}/config/{type}", "post") → operation node with summary/description.
28
+ export function findOperation(spec: OpenApiSpec, path: string, method: string): OpenApiOperation | undefined {
29
+ const item = spec.paths?.[path];
30
+ if (!item) return undefined;
31
+ return item[method.toLowerCase()];
32
+ }