@batadata/cli 0.1.5 → 0.1.7

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/api.d.ts CHANGED
@@ -14,6 +14,32 @@ export declare function request<T = unknown>(method: string, path: string, optio
14
14
  * generic placeholder so agents see what actually went wrong.
15
15
  */
16
16
  export declare function apiError(res: ApiResponse, fallback?: string): string;
17
+ export interface ResolvedTeam {
18
+ teamId: string | undefined;
19
+ /** Set when a configured `defaultTeam` existed but the active credential
20
+ * isn't a member of it, so the CLI fell back to the key's first team. */
21
+ mismatchNote?: string;
22
+ }
23
+ /**
24
+ * Pure team-resolution policy — no fs/network/process access, so it's
25
+ * unit-testable without mocking modules.
26
+ *
27
+ * - Happy path: the credential IS the stored login (tokenSource === "stored")
28
+ * and it has a saved `defaultTeam`. That team was saved BY this login, so
29
+ * it's trustworthy by construction — return it with zero requests.
30
+ * - Otherwise (a `--api-key` flag or `BATA_API_KEY` env may belong to a
31
+ * different account/key than whatever last ran `bata login` on this
32
+ * machine) — don't trust `defaultTeam` blindly. Fetch the key's actual
33
+ * teams and only use `defaultTeam` if the key is really a member; else
34
+ * fall back to the key's first team and report the mismatch.
35
+ */
36
+ export declare function pickTeamId(params: {
37
+ tokenSource: "flag" | "env" | "stored" | "none";
38
+ savedDefaultTeam: string | undefined;
39
+ fetchTeams: () => Promise<Array<{
40
+ id: string;
41
+ }> | undefined>;
42
+ }): Promise<ResolvedTeam>;
17
43
  export declare function resolveTeamId(token: string): Promise<string | undefined>;
18
44
  /**
19
45
  * List endpoints return `{ data: [...], pagination }` (not a bare array).
package/dist/api.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import * as https from "node:https";
2
2
  import * as http from "node:http";
3
3
  import { URL } from "node:url";
4
- import { getApiUrl, loadConfig } from "./config.js";
4
+ import { getApiUrl, loadConfig, getTokenSource, isJsonMode } from "./config.js";
5
+ import { colors } from "./utils/logger.js";
5
6
  export async function request(method, path, options = {}) {
6
7
  const baseUrl = getApiUrl();
7
8
  const url = new URL(path, baseUrl);
@@ -71,24 +72,65 @@ export function apiError(res, fallback = "Request failed") {
71
72
  return `${fallback} (HTTP ${res.status || "?"})`;
72
73
  }
73
74
  /**
74
- * Resolve the team id to operate on. Headless agents (just BATA_API_KEY, no
75
- * prior `bata login`) have no saved defaultTeam, and the API requires a team
76
- * for project list/create. So fall back to the user's first team. Cached for
77
- * the process lifetime. Returns undefined only if the user has no teams.
75
+ * Pure team-resolution policy no fs/network/process access, so it's
76
+ * unit-testable without mocking modules.
77
+ *
78
+ * - Happy path: the credential IS the stored login (tokenSource === "stored")
79
+ * and it has a saved `defaultTeam`. That team was saved BY this login, so
80
+ * it's trustworthy by construction — return it with zero requests.
81
+ * - Otherwise (a `--api-key` flag or `BATA_API_KEY` env may belong to a
82
+ * different account/key than whatever last ran `bata login` on this
83
+ * machine) — don't trust `defaultTeam` blindly. Fetch the key's actual
84
+ * teams and only use `defaultTeam` if the key is really a member; else
85
+ * fall back to the key's first team and report the mismatch.
86
+ */
87
+ export async function pickTeamId(params) {
88
+ const { tokenSource, savedDefaultTeam, fetchTeams } = params;
89
+ if (tokenSource === "stored" && savedDefaultTeam) {
90
+ return { teamId: savedDefaultTeam };
91
+ }
92
+ const teams = await fetchTeams();
93
+ if (!teams)
94
+ return { teamId: undefined };
95
+ if (savedDefaultTeam && teams.some((t) => t.id === savedDefaultTeam)) {
96
+ return { teamId: savedDefaultTeam };
97
+ }
98
+ const fallback = teams[0]?.id;
99
+ if (savedDefaultTeam && fallback) {
100
+ return {
101
+ teamId: fallback,
102
+ mismatchNote: `Configured default team "${savedDefaultTeam}" isn't accessible with this ` +
103
+ `API key — using "${fallback}" instead.`,
104
+ };
105
+ }
106
+ return { teamId: fallback };
107
+ }
108
+ /**
109
+ * Resolve the team id to operate on. Cached for the process lifetime (both
110
+ * the id and whether the mismatch note was already printed), since it never
111
+ * changes mid-run. Returns undefined only if the credential has no teams.
78
112
  */
79
113
  let _cachedTeamId;
114
+ let _resolved = false;
80
115
  export async function resolveTeamId(token) {
81
- const saved = loadConfig().defaultTeam;
82
- if (saved)
83
- return saved;
84
- if (_cachedTeamId)
116
+ if (_resolved)
85
117
  return _cachedTeamId;
86
- const res = await request("GET", "/v1/teams", { token });
87
- if (!res.ok)
88
- return undefined;
89
- const data = res.data;
90
- const teams = Array.isArray(data) ? data : data?.teams ?? [];
91
- _cachedTeamId = teams[0]?.id;
118
+ const result = await pickTeamId({
119
+ tokenSource: getTokenSource(),
120
+ savedDefaultTeam: loadConfig().defaultTeam,
121
+ fetchTeams: async () => {
122
+ const res = await request("GET", "/v1/teams", { token });
123
+ if (!res.ok)
124
+ return undefined;
125
+ const data = res.data;
126
+ return Array.isArray(data) ? data : data?.teams ?? [];
127
+ },
128
+ });
129
+ _cachedTeamId = result.teamId;
130
+ _resolved = true;
131
+ if (result.mismatchNote && !isJsonMode()) {
132
+ console.error(colors.dim(` ${result.mismatchNote}`));
133
+ }
92
134
  return _cachedTeamId;
93
135
  }
94
136
  /**
@@ -1,5 +1,19 @@
1
+ /**
2
+ * Only surface `defaultProject` if it's actually one of the caller's real
3
+ * projects — a saved default from another team/key/stale config must never
4
+ * be echoed back as if it were live data. Returns null otherwise.
5
+ */
6
+ export declare function validDefaultProject(projects: Array<{
7
+ id: string;
8
+ }>, defaultProject: string | undefined): string | null;
1
9
  export declare function list(): Promise<void>;
2
10
  export declare function create(): Promise<void>;
3
11
  export declare function info(projectId?: string): Promise<void>;
4
12
  export declare function deleteProject(projectId?: string): Promise<void>;
13
+ /**
14
+ * Resolve the target project from subcommand args: `--project <id>` /
15
+ * `--project=<id>` wins, otherwise the first non-flag positional. Flags like
16
+ * `--yes` / `--json` are never mistaken for a project ID.
17
+ */
18
+ export declare function resolveProjectArg(args: string[]): string | undefined;
5
19
  export declare function handleProjects(args: string[]): Promise<void>;
@@ -19,6 +19,16 @@ function formatDate(iso) {
19
19
  const d = new Date(iso);
20
20
  return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
21
21
  }
22
+ /**
23
+ * Only surface `defaultProject` if it's actually one of the caller's real
24
+ * projects — a saved default from another team/key/stale config must never
25
+ * be echoed back as if it were live data. Returns null otherwise.
26
+ */
27
+ export function validDefaultProject(projects, defaultProject) {
28
+ if (!defaultProject)
29
+ return null;
30
+ return projects.some((p) => p.id === defaultProject) ? defaultProject : null;
31
+ }
22
32
  function statusBadge(status) {
23
33
  switch (status?.toLowerCase()) {
24
34
  case "active":
@@ -55,6 +65,7 @@ export async function list() {
55
65
  }
56
66
  s?.stop();
57
67
  const projects = asList(res.data);
68
+ const defaultProj = validDefaultProject(projects, config.defaultProject);
58
69
  if (jsonMode) {
59
70
  json({
60
71
  projects: projects.map((p) => ({
@@ -64,7 +75,7 @@ export async function list() {
64
75
  status: p.status,
65
76
  created_at: projectCreatedAt(p),
66
77
  })),
67
- default_project: config.defaultProject ?? null,
78
+ default_project: defaultProj,
68
79
  count: projects.length,
69
80
  });
70
81
  return;
@@ -75,7 +86,6 @@ export async function list() {
75
86
  log();
76
87
  return;
77
88
  }
78
- const defaultProj = config.defaultProject;
79
89
  table(["NAME", "REGION", "STATUS", "CREATED"], projects.map((p) => [
80
90
  p.id === defaultProj ? `${p.name} ${colors.cyan("*")}` : p.name,
81
91
  p.region || "-",
@@ -242,15 +252,29 @@ export async function deleteProject(projectId) {
242
252
  success(`Project ${id} deleted.`);
243
253
  log();
244
254
  }
255
+ /**
256
+ * Resolve the target project from subcommand args: `--project <id>` /
257
+ * `--project=<id>` wins, otherwise the first non-flag positional. Flags like
258
+ * `--yes` / `--json` are never mistaken for a project ID.
259
+ */
260
+ export function resolveProjectArg(args) {
261
+ for (let i = 0; i < args.length; i++) {
262
+ if (args[i] === "--project" && args[i + 1])
263
+ return args[i + 1];
264
+ if (args[i].startsWith("--project="))
265
+ return args[i].slice("--project=".length);
266
+ }
267
+ return args.find((a) => !a.startsWith("-"));
268
+ }
245
269
  export async function handleProjects(args) {
246
270
  const sub = args[0];
247
271
  switch (sub) {
248
272
  case "create":
249
273
  return create();
250
274
  case "info":
251
- return info(args[1]);
275
+ return info(resolveProjectArg(args.slice(1)));
252
276
  case "delete":
253
- return deleteProject(args[1]);
277
+ return deleteProject(resolveProjectArg(args.slice(1)));
254
278
  case "list":
255
279
  case undefined:
256
280
  return list();
@@ -8,6 +8,7 @@ import { api, apiError, resolveTeamId } from "../api.js";
8
8
  import { requireToken, loadConfig, isJsonMode } from "../config.js";
9
9
  import { colors, log, json, spinner, table, heading } from "../utils/logger.js";
10
10
  import { emitError } from "../utils/errors.js";
11
+ import { validDefaultProject } from "./projects.js";
11
12
  function statusBadge(status) {
12
13
  switch (status?.toLowerCase()) {
13
14
  case "active":
@@ -55,6 +56,7 @@ export async function status() {
55
56
  }
56
57
  s?.stop();
57
58
  const projects = Array.isArray(res.data) ? res.data : [];
59
+ const defaultProj = validDefaultProject(projects, config.defaultProject);
58
60
  if (jsonMode) {
59
61
  json({
60
62
  projects: projects.map((p) => ({
@@ -66,7 +68,7 @@ export async function status() {
66
68
  branch_count: p.branches?.length ?? 0,
67
69
  compute_status: p.computes?.[0]?.status ?? p.status ?? null,
68
70
  })),
69
- default_project: config.defaultProject ?? null,
71
+ default_project: defaultProj,
70
72
  count: projects.length,
71
73
  });
72
74
  return;
@@ -77,7 +79,6 @@ export async function status() {
77
79
  log();
78
80
  return;
79
81
  }
80
- const defaultProj = config.defaultProject;
81
82
  // Fetch branch/compute details for each project
82
83
  const rows = [];
83
84
  for (const p of projects) {
package/dist/config.d.ts CHANGED
@@ -30,6 +30,14 @@ export declare function clearConfig(): void;
30
30
  * Returns undefined if none is available.
31
31
  */
32
32
  export declare function getToken(): string | undefined;
33
+ export type TokenSource = "flag" | "env" | "stored" | "none";
34
+ /**
35
+ * Where the active credential came from, in the same priority order as
36
+ * getToken(). Callers use this to decide whether it's safe to trust
37
+ * config saved by a *different* credential (e.g. a `defaultTeam` written by
38
+ * a previous `bata login`) — it only is when the token IS that stored login.
39
+ */
40
+ export declare function getTokenSource(): TokenSource;
33
41
  /**
34
42
  * Like getToken() but exits with a clear, agent-friendly error if no
35
43
  * credential can be found anywhere.
package/dist/config.js CHANGED
@@ -68,6 +68,21 @@ export function getToken() {
68
68
  return process.env.BATA_API_KEY;
69
69
  return loadConfig().token;
70
70
  }
71
+ /**
72
+ * Where the active credential came from, in the same priority order as
73
+ * getToken(). Callers use this to decide whether it's safe to trust
74
+ * config saved by a *different* credential (e.g. a `defaultTeam` written by
75
+ * a previous `bata login`) — it only is when the token IS that stored login.
76
+ */
77
+ export function getTokenSource() {
78
+ if (runtime.apiKey)
79
+ return "flag";
80
+ if (process.env.BATA_API_KEY)
81
+ return "env";
82
+ if (loadConfig().token)
83
+ return "stored";
84
+ return "none";
85
+ }
71
86
  /**
72
87
  * Like getToken() but exits with a clear, agent-friendly error if no
73
88
  * credential can be found anywhere.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"
@@ -10,7 +10,8 @@
10
10
  "build": "tsc -p tsconfig.build.json",
11
11
  "dev": "tsc -p tsconfig.build.json --watch",
12
12
  "typecheck": "tsc --noEmit",
13
- "test": "vitest run"
13
+ "test": "node --test \"test/*.test.mjs\"",
14
+ "pretest": "npm run build"
14
15
  },
15
16
  "engines": {
16
17
  "node": ">=20.0.0"