@batadata/cli 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -43,6 +43,45 @@ bata connect my-app # open psql (auto-wakes the compute if suspended)
43
43
  bata usage # per-dimension cost for the current billing period
44
44
  ```
45
45
 
46
+ ## Project linking
47
+
48
+ Tired of threading `--project <id>` through every command? Link a directory to a
49
+ project once — Vercel/Neon-style — and every command run inside it (or any
50
+ subdirectory) targets that project automatically.
51
+
52
+ ```bash
53
+ bata link my-app # resolves by name or id, writes .batadata/project.json
54
+ bata link # no argument, in a TTY: pick from a list
55
+ bata link --status # show what this directory resolves to
56
+ bata db query "SELECT 1" # no --project needed anymore
57
+ bata unlink # remove the link
58
+ ```
59
+
60
+ `bata link` writes `.batadata/project.json` in the current directory:
61
+
62
+ ```json
63
+ { "projectId": "proj_abc123", "branchId": null }
64
+ ```
65
+
66
+ **Add `.batadata/` to your `.gitignore`** — the link is per-checkout, not shared.
67
+
68
+ ### Pin a branch
69
+
70
+ ```bash
71
+ bata db branch checkout preview # writes the branch id into .batadata/project.json
72
+ bata db query "SELECT 1" # now runs against `preview` — no --branch flag
73
+ ```
74
+
75
+ ### Resolution precedence
76
+
77
+ Everywhere a project or branch is resolved, the same rule applies — **an explicit
78
+ flag always wins**, so linking never silently overrides an intentional request:
79
+
80
+ 1. an explicit `--project` / `--branch` flag (or a positional id)
81
+ 2. `.batadata/project.json`, discovered by walking **up** from the current
82
+ directory like git (stops at `$HOME` or the filesystem root)
83
+ 3. `defaultProject` in `~/.batarc` (the per-machine fallback)
84
+
46
85
  ## Commands
47
86
 
48
87
  | Command | Description |
@@ -51,6 +90,8 @@ bata usage # per-dimension cost for the current billing period
51
90
  | `connect <name>` | Open `psql` to a project (auto-wakes if suspended) |
52
91
  | `status` | Show all projects and their status |
53
92
  | `usage` | Show per-dimension cost (compute, storage, transfer) for the current period |
93
+ | `link [project]` | Link the current directory to a project (see [Project linking](#project-linking)) |
94
+ | `unlink` | Remove the current directory's project link |
54
95
  | `login` / `logout` / `whoami` | Manage your session |
55
96
  | `api-keys` | Create / list / revoke API keys |
56
97
  | `projects` | `list`, `create`, `info`, `delete` |
@@ -59,6 +100,7 @@ bata usage # per-dimension cost for the current billing period
59
100
  | `db query <sql>` | Run a SQL query and print the rows |
60
101
  | `db branches` | List database branches |
61
102
  | `db branch create` / `db branch delete` | Manage branches |
103
+ | `db branch checkout <name-or-id>` | Pin a branch into the directory's link |
62
104
  | `db studio` | Open the table browser in your browser |
63
105
  | `schema check <file>` | Check a proposed schema change against live query traffic |
64
106
  | `generate` | Generate types from your database schema (`--watch` for watch mode) |
@@ -125,6 +167,11 @@ bata projects delete --yes --json
125
167
  exists but its compute may still be provisioning. Poll `bata db branches --json`
126
168
  until the branch reports a ready status before connecting to it.
127
169
 
170
+ To avoid passing `--project` on every call, an agent can `bata link <id> --json`
171
+ once (it emits `{ "linked": true, "project_id", "project_name", "branch_id",
172
+ "link_file" }`) and drop the flag from every subsequent command in that
173
+ directory. `bata link --status --json` reports the current link.
174
+
128
175
  ### Cost truth
129
176
 
130
177
  `bata usage` reports cost per dimension and is deliberately honest about what is
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
  /**
@@ -9,6 +9,7 @@ import { api } from "../api.js";
9
9
  import { requireToken, loadConfig, isJsonMode } from "../config.js";
10
10
  import { colors, log, error, spinner, info as logInfo } from "../utils/logger.js";
11
11
  import { emitError } from "../utils/errors.js";
12
+ import { resolveProjectId } from "../link.js";
12
13
  export async function connect(args) {
13
14
  // psql is an interactive session — there's no headless equivalent. Don't spawn
14
15
  // it in --json or non-TTY contexts; point agents at the headless surfaces.
@@ -17,8 +18,9 @@ export async function connect(args) {
17
18
  }
18
19
  const token = requireToken();
19
20
  const config = loadConfig();
20
- // Accept project name as argument, or use default
21
- let projectId = config.defaultProject;
21
+ // Accept project name as argument, or use the resolved default
22
+ // (--project isn't a connect flag; precedence is link > config here).
23
+ let projectId = resolveProjectId().projectId;
22
24
  const projectName = args[0];
23
25
  if (projectName && !projectName.startsWith("-")) {
24
26
  // Resolve project name to ID
@@ -1,8 +1,35 @@
1
+ /**
2
+ * Parse a relative duration like `30m`, `2h`, `7d` (units: s/m/h/d/w) into
3
+ * milliseconds. Returns `{ error }` for anything malformed or non-positive so
4
+ * the caller can surface a clean CLI error instead of minting a bad TTL.
5
+ * Exported for unit testing.
6
+ */
7
+ export declare function parseDuration(input: string): {
8
+ ms?: number;
9
+ error?: string;
10
+ };
11
+ /**
12
+ * Split `db branch create` args into the positional name plus the
13
+ * `--expires-in` / `--purpose` flags (both `--flag value` and `--flag=value`
14
+ * forms). Keeps the command order-independent, like `db query`'s `--branch`.
15
+ */
16
+ export declare function parseBranchCreateArgs(args: string[]): {
17
+ name?: string;
18
+ expiresIn?: string;
19
+ purpose?: string;
20
+ };
1
21
  export declare function connect(): Promise<void>;
2
22
  export declare function url(): Promise<void>;
3
23
  export declare function branches(): Promise<void>;
4
- export declare function branchCreate(name?: string): Promise<void>;
24
+ export declare function branchCreate(args?: string[]): Promise<void>;
5
25
  export declare function branchDelete(name?: string): Promise<void>;
26
+ /**
27
+ * `bata db branch checkout <name-or-id>` — pin a branch into the directory's
28
+ * `.batadata/project.json` so later `db query` / `db url` target it with no
29
+ * `--branch` flag. Requires a linked project (or an explicit `--project`); the
30
+ * branch ref is resolved by name OR id, just like `db query --branch`.
31
+ */
32
+ export declare function branchCheckout(args: string[]): Promise<void>;
6
33
  export declare function studio(): Promise<void>;
7
34
  export declare function query(args?: string[]): Promise<void>;
8
35
  export declare function handleDb(args: string[]): Promise<void>;
@@ -1,10 +1,12 @@
1
1
  import { execSync, spawn } from "node:child_process";
2
+ import * as path from "node:path";
2
3
  import { api, apiError } from "../api.js";
3
4
  import { requireToken, loadConfig, isJsonMode } from "../config.js";
4
- import { colors, log, json, error, spinner, table, heading, info as logInfo } from "../utils/logger.js";
5
+ import { colors, log, json, error, success, spinner, table, heading, info as logInfo } from "../utils/logger.js";
5
6
  import { prompt, confirmDestructive } from "../utils/prompts.js";
6
7
  import { openBrowser } from "../utils/open.js";
7
8
  import { emitError, isRetryable } from "../utils/errors.js";
9
+ import { resolveProjectId, resolveBranchId, findLinkFile, writeLinkFile } from "../link.js";
8
10
  async function getConnectionInfo(projectId, token) {
9
11
  // reveal=true so the returned string is actually usable (the owner is asking).
10
12
  const res = await api.get(`/v1/connection-info/${projectId}`, token, { reveal: "true" });
@@ -30,6 +32,59 @@ function formatDate(iso) {
30
32
  const d = new Date(iso);
31
33
  return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
32
34
  }
35
+ const DURATION_UNIT_MS = {
36
+ s: 1_000,
37
+ m: 60_000,
38
+ h: 3_600_000,
39
+ d: 86_400_000,
40
+ w: 604_800_000,
41
+ };
42
+ /**
43
+ * Parse a relative duration like `30m`, `2h`, `7d` (units: s/m/h/d/w) into
44
+ * milliseconds. Returns `{ error }` for anything malformed or non-positive so
45
+ * the caller can surface a clean CLI error instead of minting a bad TTL.
46
+ * Exported for unit testing.
47
+ */
48
+ export function parseDuration(input) {
49
+ const match = /^(\d+)(s|m|h|d|w)$/.exec(input.trim());
50
+ if (!match) {
51
+ return { error: `Invalid duration "${input}". Use forms like 30m, 2h, 7d (units: s/m/h/d/w).` };
52
+ }
53
+ const n = parseInt(match[1], 10);
54
+ if (n <= 0) {
55
+ return { error: `Duration "${input}" must be greater than zero.` };
56
+ }
57
+ return { ms: n * DURATION_UNIT_MS[match[2]] };
58
+ }
59
+ /**
60
+ * Split `db branch create` args into the positional name plus the
61
+ * `--expires-in` / `--purpose` flags (both `--flag value` and `--flag=value`
62
+ * forms). Keeps the command order-independent, like `db query`'s `--branch`.
63
+ */
64
+ export function parseBranchCreateArgs(args) {
65
+ let name;
66
+ let expiresIn;
67
+ let purpose;
68
+ for (let i = 0; i < args.length; i++) {
69
+ const arg = args[i];
70
+ if (arg === "--expires-in") {
71
+ expiresIn = args[++i];
72
+ }
73
+ else if (arg.startsWith("--expires-in=")) {
74
+ expiresIn = arg.slice("--expires-in=".length);
75
+ }
76
+ else if (arg === "--purpose") {
77
+ purpose = args[++i];
78
+ }
79
+ else if (arg.startsWith("--purpose=")) {
80
+ purpose = arg.slice("--purpose=".length);
81
+ }
82
+ else if (!arg.startsWith("--") && name === undefined) {
83
+ name = arg;
84
+ }
85
+ }
86
+ return { name, expiresIn, purpose };
87
+ }
33
88
  /**
34
89
  * Human-readable STATUS cell for a branch. Prefers the live compute lifecycle
35
90
  * (computeStatus) over the static branch row status, and appends a green check
@@ -85,7 +140,7 @@ export async function connect() {
85
140
  }
86
141
  const token = requireToken();
87
142
  const config = loadConfig();
88
- const projectId = config.defaultProject;
143
+ const projectId = resolveProjectId().projectId;
89
144
  if (!projectId) {
90
145
  emitError("NO_PROJECT", "No default project.", "Run bata projects create or set one with bata projects info <id>.");
91
146
  }
@@ -119,7 +174,7 @@ export async function connect() {
119
174
  export async function url() {
120
175
  const token = requireToken();
121
176
  const config = loadConfig();
122
- const projectId = config.defaultProject;
177
+ const projectId = resolveProjectId().projectId;
123
178
  if (!projectId) {
124
179
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
125
180
  }
@@ -138,7 +193,7 @@ export async function branches() {
138
193
  const token = requireToken();
139
194
  const config = loadConfig();
140
195
  const jsonMode = isJsonMode();
141
- const projectId = config.defaultProject;
196
+ const projectId = resolveProjectId().projectId;
142
197
  if (!projectId) {
143
198
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
144
199
  }
@@ -161,6 +216,9 @@ export async function branches() {
161
216
  // and a successful query.
162
217
  computeStatus: b.computeStatus ?? null,
163
218
  ready: b.ready ?? null,
219
+ // Ephemeral-branch TTL: when set, the branch is auto-reaped after this.
220
+ expires_at: b.expiresAt ?? null,
221
+ purpose: b.purpose ?? null,
164
222
  created_at: b.createdAt ?? null,
165
223
  })),
166
224
  count: branchList.length,
@@ -173,24 +231,37 @@ export async function branches() {
173
231
  log();
174
232
  return;
175
233
  }
176
- table(["NAME", "PRIMARY", "STATUS", "CREATED"], branchList.map((b) => [
234
+ table(["NAME", "PRIMARY", "STATUS", "EXPIRES", "CREATED"], branchList.map((b) => [
177
235
  b.name,
178
236
  b.isPrimary ? colors.green("yes") : "-",
179
237
  // Live compute lifecycle (computeStatus), with a check once ready so the
180
238
  // column is scannable.
181
239
  branchStatusLabel(b),
240
+ // TTL'd branches show their reap date; permanent branches show "-".
241
+ b.expiresAt ? formatDate(b.expiresAt) : "-",
182
242
  formatDate(b.createdAt ?? ""),
183
243
  ]));
184
244
  log();
185
245
  }
186
- export async function branchCreate(name) {
246
+ export async function branchCreate(args = []) {
187
247
  const jsonMode = isJsonMode();
188
248
  const token = requireToken();
189
249
  const config = loadConfig();
190
- const projectId = config.defaultProject;
250
+ const projectId = resolveProjectId().projectId;
191
251
  if (!projectId) {
192
252
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
193
253
  }
254
+ const { name, expiresIn, purpose } = parseBranchCreateArgs(args);
255
+ // A relative TTL (--expires-in 2h) becomes an absolute expires_at the server
256
+ // enforces. Parse it up front so a bad duration fails fast, before any call.
257
+ let expiresAt;
258
+ if (expiresIn !== undefined) {
259
+ const parsed = parseDuration(expiresIn);
260
+ if (parsed.error) {
261
+ emitError("INVALID_FLAG", parsed.error, "Usage: bata db branch create <name> --expires-in 2h");
262
+ }
263
+ expiresAt = new Date(Date.now() + parsed.ms).toISOString();
264
+ }
194
265
  // Don't block on an interactive prompt headlessly.
195
266
  let branchName = name;
196
267
  if (!branchName) {
@@ -209,6 +280,10 @@ export async function branchCreate(name) {
209
280
  };
210
281
  if (config.defaultTeam)
211
282
  body.team_id = config.defaultTeam;
283
+ if (expiresAt)
284
+ body.expires_at = expiresAt;
285
+ if (purpose)
286
+ body.purpose = purpose;
212
287
  const res = await api.post("/v1/branches", body, token);
213
288
  s?.stop();
214
289
  if (!res.ok) {
@@ -216,7 +291,13 @@ export async function branchCreate(name) {
216
291
  }
217
292
  if (jsonMode) {
218
293
  json({
219
- branch: { id: res.data.id, name: res.data.name ?? branchName, project_id: projectId },
294
+ branch: {
295
+ id: res.data.id,
296
+ name: res.data.name ?? branchName,
297
+ project_id: projectId,
298
+ expires_at: res.data.expiresAt ?? expiresAt ?? null,
299
+ purpose: res.data.purpose ?? purpose ?? null,
300
+ },
220
301
  // The branch row exists immediately, but its compute may still be
221
302
  // provisioning — poll `db branches` for status before connecting.
222
303
  ready: false,
@@ -226,6 +307,9 @@ export async function branchCreate(name) {
226
307
  }
227
308
  log();
228
309
  log(` ${colors.green(">")} Branch ${colors.cyan(branchName)} created`);
310
+ if (expiresAt) {
311
+ log(` ${colors.dim("Expires")} ${formatDate(expiresAt)} ${colors.dim("(auto-deleted)")}`);
312
+ }
229
313
  log(` ${colors.dim("Poll readiness with")} ${colors.cyan("bata db branches")}`);
230
314
  log();
231
315
  }
@@ -233,7 +317,7 @@ export async function branchDelete(name) {
233
317
  const jsonMode = isJsonMode();
234
318
  const token = requireToken();
235
319
  const config = loadConfig();
236
- const projectId = config.defaultProject;
320
+ const projectId = resolveProjectId().projectId;
237
321
  if (!projectId) {
238
322
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
239
323
  }
@@ -275,9 +359,73 @@ export async function branchDelete(name) {
275
359
  log(` ${colors.green(">")} Branch ${colors.cyan(name)} deleted`);
276
360
  log();
277
361
  }
278
- export async function studio() {
362
+ /**
363
+ * Pull a `--project <id>` / `--project=<id>` flag out of args, returning the
364
+ * explicit project id (if any) and the remaining positionals. Lets `db branch
365
+ * checkout` accept `--project` while the positional stays the branch ref.
366
+ */
367
+ function parseProjectFlag(args) {
368
+ let projectId;
369
+ const rest = [];
370
+ for (let i = 0; i < args.length; i++) {
371
+ const arg = args[i];
372
+ if (arg === "--project")
373
+ projectId = args[++i];
374
+ else if (arg.startsWith("--project="))
375
+ projectId = arg.slice("--project=".length);
376
+ else
377
+ rest.push(arg);
378
+ }
379
+ return { projectId, rest };
380
+ }
381
+ /**
382
+ * `bata db branch checkout <name-or-id>` — pin a branch into the directory's
383
+ * `.batadata/project.json` so later `db query` / `db url` target it with no
384
+ * `--branch` flag. Requires a linked project (or an explicit `--project`); the
385
+ * branch ref is resolved by name OR id, just like `db query --branch`.
386
+ */
387
+ export async function branchCheckout(args) {
388
+ const jsonMode = isJsonMode();
389
+ const { projectId: projectFlag, rest } = parseProjectFlag(args);
390
+ const ref = rest[0];
391
+ if (!ref) {
392
+ emitError("MISSING_ARG", "Branch name or id is required.", "Usage: bata db branch checkout <name-or-id>");
393
+ }
394
+ const { projectId } = resolveProjectId(projectFlag);
395
+ if (!projectId) {
396
+ emitError("NO_PROJECT", "No linked project.", "Run `bata link <project>` first, or pass --project <id>.");
397
+ }
398
+ const token = requireToken();
279
399
  const config = loadConfig();
280
- const projectId = config.defaultProject;
400
+ const s = jsonMode ? null : spinner(`Resolving branch ${colors.cyan(ref)}`);
401
+ const branch = await resolveBranchRef(projectId, token, config.defaultTeam, ref);
402
+ s?.stop();
403
+ if (!branch) {
404
+ emitError("BRANCH_NOT_FOUND", `Branch "${ref}" not found in this project.`, "List branches with: bata db branches --json");
405
+ }
406
+ // Write into the nearest existing link file's directory if there is one
407
+ // (so `checkout` updates the same link `link`/`query` read); else create one
408
+ // in the CWD.
409
+ const existing = findLinkFile();
410
+ const targetDir = existing ? path.dirname(path.dirname(existing)) : process.cwd();
411
+ const linkFile = writeLinkFile(targetDir, { projectId, branchId: branch.id });
412
+ if (jsonMode) {
413
+ json({
414
+ project_id: projectId,
415
+ branch_id: branch.id,
416
+ branch_name: branch.name,
417
+ link_file: linkFile,
418
+ });
419
+ return;
420
+ }
421
+ log();
422
+ success(`Checked out branch ${colors.cyan(branch.name)} ${colors.dim(branch.id)}`);
423
+ log(` ${colors.dim("Project:")} ${colors.dim(projectId)}`);
424
+ log(` ${colors.dim("Link file:")} ${colors.dim(linkFile)}`);
425
+ log();
426
+ }
427
+ export async function studio() {
428
+ const projectId = resolveProjectId().projectId;
281
429
  const studioUrl = projectId
282
430
  ? `https://bench-app-one.vercel.app/studio?project=${projectId}`
283
431
  : "https://bench-app-one.vercel.app/studio";
@@ -313,14 +461,17 @@ function parseBranchFlag(args) {
313
461
  }
314
462
  export async function query(args = []) {
315
463
  const jsonMode = isJsonMode();
316
- const { branchId, rest } = parseBranchFlag(args);
464
+ const { branchId: branchFlag, rest } = parseBranchFlag(args);
465
+ // Branch precedence: an explicit --branch wins, else the branch pinned by
466
+ // `bata db branch checkout` in .batadata/project.json, else the primary.
467
+ const branchId = resolveBranchId(branchFlag).branchId;
317
468
  const sql = rest.join(" ").trim();
318
469
  if (!sql) {
319
470
  emitError("MISSING_ARG", "SQL query is required.", 'Usage: bata db query "SELECT 1"');
320
471
  }
321
472
  const token = requireToken();
322
473
  const config = loadConfig();
323
- const projectId = config.defaultProject;
474
+ const projectId = resolveProjectId().projectId;
324
475
  if (!projectId) {
325
476
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
326
477
  }
@@ -442,10 +593,16 @@ function dbHelp(sub) {
442
593
  note("--json includes computeStatus + ready — poll these after create/cold start.");
443
594
  break;
444
595
  case "branch":
445
- log(` ${colors.bold("bata db branch")} — create or delete a branch`);
596
+ log(` ${colors.bold("bata db branch")} — create, delete, or check out a branch`);
446
597
  log();
447
- usage("bata db branch create <name>");
598
+ usage("bata db branch create <name> [--expires-in <2h|30m|7d>] [--purpose <text>]");
448
599
  usage("bata db branch delete <name> [--yes]");
600
+ usage("bata db branch checkout <name-or-id> [--project <id>]");
601
+ log();
602
+ note("--expires-in Auto-delete the branch after this long (units: s/m/h/d/w).");
603
+ note("--purpose Free-text note describing why the branch exists.");
604
+ note("checkout pins the branch into .batadata/project.json so db query/url");
605
+ note("target it without a --branch flag. Needs a linked project (bata link).");
449
606
  break;
450
607
  case "url":
451
608
  log(` ${colors.bold("bata db url")} — print the connection string`);
@@ -471,6 +628,7 @@ function dbHelp(sub) {
471
628
  usage("bata db branches List branches + compute status");
472
629
  usage("bata db branch create Create a new branch");
473
630
  usage("bata db branch delete Delete a branch");
631
+ usage("bata db branch checkout Pin a branch into .batadata/project.json");
474
632
  usage("bata db studio Open the table browser");
475
633
  usage("bata db query <sql> Run a SQL query (--branch <id-or-name> to target a branch)");
476
634
  log();
@@ -497,10 +655,12 @@ export async function handleDb(args) {
497
655
  case "branch": {
498
656
  const action = args[1];
499
657
  if (action === "create")
500
- return branchCreate(args[2]);
658
+ return branchCreate(args.slice(2));
501
659
  if (action === "delete")
502
660
  return branchDelete(args[2]);
503
- emitError("INVALID_FLAG", `Unknown: db branch ${action || ""}`, "Available: create, delete");
661
+ if (action === "checkout")
662
+ return branchCheckout(args.slice(2));
663
+ emitError("INVALID_FLAG", `Unknown: db branch ${action || ""}`, "Available: create, delete, checkout");
504
664
  }
505
665
  case "studio":
506
666
  return studio();
@@ -0,0 +1,2 @@
1
+ export declare function link(args: string[]): Promise<void>;
2
+ export declare function unlink(): void;
@@ -0,0 +1,138 @@
1
+ import { api, apiError, resolveTeamId, asList } from "../api.js";
2
+ import { requireToken, isJsonMode } from "../config.js";
3
+ import { colors, log, json, success, spinner } from "../utils/logger.js";
4
+ import { emitError } from "../utils/errors.js";
5
+ import { select } from "../utils/prompts.js";
6
+ import { LINK_DIR, findLinkFile, readLinkFile, writeLinkFile, removeLinkFile, } from "../link.js";
7
+ /**
8
+ * Pull `--status` out of the args and return the remaining positionals. Global
9
+ * flags (`--json` etc.) are stripped upstream by parseGlobalFlags, so anything
10
+ * still `--`-prefixed here is a link-specific flag.
11
+ */
12
+ function parseLinkArgs(args) {
13
+ let status = false;
14
+ let positional;
15
+ for (const arg of args) {
16
+ if (arg === "--status")
17
+ status = true;
18
+ else if (!arg.startsWith("-") && positional === undefined)
19
+ positional = arg;
20
+ }
21
+ return { status, positional };
22
+ }
23
+ /** Fetch the caller's projects (team-scoped, exactly like `projects list`). */
24
+ async function fetchProjects(token) {
25
+ const teamId = await resolveTeamId(token);
26
+ if (!teamId) {
27
+ emitError("NO_TEAM", "No team found for this credential.", "This API key isn't attached to a team. Run `bata login` or check `bata whoami`.");
28
+ }
29
+ const res = await api.get("/v1/projects", token, { team_id: teamId });
30
+ if (!res.ok) {
31
+ emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
32
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
33
+ : "CLI_ERROR", apiError(res, "Failed to fetch projects"), "");
34
+ }
35
+ return asList(res.data);
36
+ }
37
+ /** Show what (if anything) the current directory resolves to. */
38
+ function linkStatus() {
39
+ const found = readLinkFile();
40
+ const jsonMode = isJsonMode();
41
+ if (!found) {
42
+ if (jsonMode) {
43
+ json({ linked: false, project_id: null, branch_id: null, link_file: null });
44
+ return;
45
+ }
46
+ log();
47
+ log(` ${colors.dim("No project linked in this directory.")}`);
48
+ log(` Run ${colors.cyan("bata link <project>")} to link one.`);
49
+ log();
50
+ return;
51
+ }
52
+ const { path: linkFile, link } = found;
53
+ if (jsonMode) {
54
+ json({
55
+ linked: true,
56
+ project_id: link.projectId,
57
+ branch_id: link.branchId ?? null,
58
+ link_file: linkFile,
59
+ });
60
+ return;
61
+ }
62
+ log();
63
+ success(`Linked to project ${colors.cyan(link.projectId)}`);
64
+ log(` ${colors.dim("Branch:")} ${link.branchId ? colors.cyan(link.branchId) : colors.dim("(none)")}`);
65
+ log(` ${colors.dim("Link file:")} ${colors.dim(linkFile)}`);
66
+ log();
67
+ }
68
+ export async function link(args) {
69
+ const { status, positional } = parseLinkArgs(args);
70
+ const jsonMode = isJsonMode();
71
+ if (status) {
72
+ linkStatus();
73
+ return;
74
+ }
75
+ // Input-contract check first (no token needed): headless with no project is a
76
+ // hard MISSING_ARG — never guess a project for an agent, never hang on a
77
+ // prompt. Doing this before requireToken() gives the clearer error.
78
+ if (!positional && (jsonMode || !process.stdin.isTTY)) {
79
+ emitError("MISSING_ARG", "No project specified.", "Pass a project id or name: bata link <project> (interactive selection needs a TTY).");
80
+ }
81
+ const token = requireToken();
82
+ let target;
83
+ if (positional) {
84
+ // Resolve by id OR name — the same string a user passes to other commands
85
+ // (mirrors how connect.ts and db.ts resolve --branch by name|id).
86
+ const s = jsonMode ? null : spinner("Resolving project");
87
+ const projects = await fetchProjects(token);
88
+ s?.stop();
89
+ target = projects.find((p) => p.id === positional || p.name === positional);
90
+ if (!target) {
91
+ emitError("NOT_FOUND", `Project "${positional}" not found.`, "List your projects with: bata projects list --json");
92
+ }
93
+ }
94
+ else {
95
+ // No argument in an interactive TTY → pick from a list.
96
+ const projects = await fetchProjects(token);
97
+ if (projects.length === 0) {
98
+ emitError("NOT_FOUND", "You have no projects to link.", "Create one with: bata create <name>");
99
+ }
100
+ const chosen = await select("Select a project to link", projects.map((p) => ({ label: `${p.name} ${colors.dim(p.id)}`, value: p.id })));
101
+ target = projects.find((p) => p.id === chosen);
102
+ }
103
+ const linkFile = writeLinkFile(process.cwd(), { projectId: target.id, branchId: null });
104
+ if (jsonMode) {
105
+ json({
106
+ linked: true,
107
+ project_id: target.id,
108
+ project_name: target.name,
109
+ branch_id: null,
110
+ link_file: linkFile,
111
+ });
112
+ return;
113
+ }
114
+ log();
115
+ success(`Linked to ${colors.cyan(target.name)} ${colors.dim(target.id)}`);
116
+ log(` ${colors.dim("Wrote")} ${colors.dim(linkFile)}`);
117
+ log();
118
+ log(` ${colors.dim("Commands in this directory now target this project — no")} ${colors.cyan("--project")} ${colors.dim("needed.")}`);
119
+ log(` ${colors.dim("Tip: add")} ${colors.cyan(`${LINK_DIR}/`)} ${colors.dim("to your .gitignore.")}`);
120
+ log();
121
+ }
122
+ export function unlink() {
123
+ const jsonMode = isJsonMode();
124
+ const existed = findLinkFile();
125
+ const removed = removeLinkFile();
126
+ if (jsonMode) {
127
+ json({ unlinked: Boolean(existed), link_file: removed });
128
+ return;
129
+ }
130
+ log();
131
+ if (removed) {
132
+ success(`Unlinked — removed ${colors.dim(removed)}`);
133
+ }
134
+ else {
135
+ log(` ${colors.dim("No project link found in this directory.")}`);
136
+ }
137
+ log();
138
+ }
@@ -1,3 +1,11 @@
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>;
@@ -3,6 +3,7 @@ import { requireToken, loadConfig, saveConfig, isJsonMode } from "../config.js";
3
3
  import { colors, log, json, success, spinner, table, kvList, heading } from "../utils/logger.js";
4
4
  import { prompt, confirmDestructive, select } from "../utils/prompts.js";
5
5
  import { emitError } from "../utils/errors.js";
6
+ import { resolveProjectId } from "../link.js";
6
7
  function projectCreatedAt(p) {
7
8
  return p.created_at ?? p.createdAt ?? "";
8
9
  }
@@ -19,6 +20,16 @@ function formatDate(iso) {
19
20
  const d = new Date(iso);
20
21
  return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
21
22
  }
23
+ /**
24
+ * Only surface `defaultProject` if it's actually one of the caller's real
25
+ * projects — a saved default from another team/key/stale config must never
26
+ * be echoed back as if it were live data. Returns null otherwise.
27
+ */
28
+ export function validDefaultProject(projects, defaultProject) {
29
+ if (!defaultProject)
30
+ return null;
31
+ return projects.some((p) => p.id === defaultProject) ? defaultProject : null;
32
+ }
22
33
  function statusBadge(status) {
23
34
  switch (status?.toLowerCase()) {
24
35
  case "active":
@@ -55,6 +66,7 @@ export async function list() {
55
66
  }
56
67
  s?.stop();
57
68
  const projects = asList(res.data);
69
+ const defaultProj = validDefaultProject(projects, config.defaultProject);
58
70
  if (jsonMode) {
59
71
  json({
60
72
  projects: projects.map((p) => ({
@@ -64,7 +76,7 @@ export async function list() {
64
76
  status: p.status,
65
77
  created_at: projectCreatedAt(p),
66
78
  })),
67
- default_project: config.defaultProject ?? null,
79
+ default_project: defaultProj,
68
80
  count: projects.length,
69
81
  });
70
82
  return;
@@ -75,7 +87,6 @@ export async function list() {
75
87
  log();
76
88
  return;
77
89
  }
78
- const defaultProj = config.defaultProject;
79
90
  table(["NAME", "REGION", "STATUS", "CREATED"], projects.map((p) => [
80
91
  p.id === defaultProj ? `${p.name} ${colors.cyan("*")}` : p.name,
81
92
  p.region || "-",
@@ -124,11 +135,11 @@ export async function create() {
124
135
  }
125
136
  export async function info(projectId) {
126
137
  const token = requireToken();
127
- const config = loadConfig();
128
138
  const jsonMode = isJsonMode();
129
- const id = projectId || config.defaultProject;
139
+ // Precedence: explicit arg/--project > .batadata link > config default.
140
+ const id = resolveProjectId(projectId).projectId;
130
141
  if (!id) {
131
- emitError("NO_PROJECT", "No project specified.", "Pass a project ID or set a default with bata projects create.");
142
+ emitError("NO_PROJECT", "No project specified.", "Pass a project ID, run `bata link <project>`, or set a default with bata projects create.");
132
143
  }
133
144
  const s = jsonMode ? null : spinner("Fetching project details");
134
145
  const teamId = await resolveTeamId(token);
@@ -203,7 +214,8 @@ export async function deleteProject(projectId) {
203
214
  const jsonMode = isJsonMode();
204
215
  const token = requireToken();
205
216
  const config = loadConfig();
206
- const id = projectId || config.defaultProject;
217
+ // Precedence: explicit arg/--project > .batadata link > config default.
218
+ const id = resolveProjectId(projectId).projectId;
207
219
  if (!id) {
208
220
  emitError("NO_PROJECT", "No project specified.", "Pass a project ID: bata projects delete <id>");
209
221
  }
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { api, apiError } from "../api.js";
3
- import { requireToken, loadConfig, isJsonMode } from "../config.js";
3
+ import { requireToken, isJsonMode } from "../config.js";
4
4
  import { colors, log, json, spinner, table, heading } from "../utils/logger.js";
5
5
  import { emitError } from "../utils/errors.js";
6
+ import { resolveProjectId } from "../link.js";
6
7
  const NOT_IMPLEMENTED_HINT = "Use `bata schema check <file.sql> --fail-on breaking` to gate migrations today.";
7
8
  /**
8
9
  * Unimplemented schema subcommand. Never exits 0 for a no-op: emits the
@@ -90,10 +91,10 @@ async function schemaCheck(args) {
90
91
  emitError("EMPTY_INPUT", "DDL input was empty.", "Provide a schema change to check, e.g. ALTER TABLE orders DROP COLUMN status;");
91
92
  }
92
93
  const token = requireToken();
93
- const config = loadConfig();
94
- const projectId = config.defaultProject;
94
+ // Precedence: .batadata link > config default (schema check has no --project).
95
+ const projectId = resolveProjectId().projectId;
95
96
  if (!projectId) {
96
- emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
97
+ emitError("NO_PROJECT", "No default project set.", "Run `bata link <project>` or set a default with: bata projects info <id>");
97
98
  }
98
99
  const body = { sql: ddl, timeRange };
99
100
  if (branchId)
@@ -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/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { create } from "./commands/create.js";
11
11
  import { status } from "./commands/status.js";
12
12
  import { connect } from "./commands/connect.js";
13
13
  import { usage } from "./commands/usage.js";
14
+ import { link, unlink } from "./commands/link.js";
14
15
  import { parseGlobalFlags } from "./args.js";
15
16
  import { isJsonMode } from "./config.js";
16
17
  import { colors, log, banner } from "./utils/logger.js";
@@ -26,6 +27,8 @@ function help() {
26
27
  log(` ${colors.cyan("connect <name>")} Open psql to a project (auto-wakes if suspended)`);
27
28
  log(` ${colors.cyan("status")} Show all projects and their status`);
28
29
  log(` ${colors.cyan("usage")} Per-dimension cost for the current period`);
30
+ log(` ${colors.cyan("link [project]")} Link this directory to a project ${colors.dim("(no more --project)")}`);
31
+ log(` ${colors.cyan("unlink")} Remove this directory's project link`);
29
32
  log();
30
33
  log(` ${colors.bold("Auth")}`);
31
34
  log(` ${colors.cyan("login")} Log in to BataDB`);
@@ -46,6 +49,7 @@ function help() {
46
49
  log(` ${colors.cyan("db branches")} List database branches ${colors.dim("(STATUS shows compute readiness)")}`);
47
50
  log(` ${colors.cyan("db branch create")} Create a new branch`);
48
51
  log(` ${colors.cyan("db branch delete")} Delete a branch`);
52
+ log(` ${colors.cyan("db branch checkout")} Pin a branch into ${colors.dim(".batadata/project.json")}`);
49
53
  log(` ${colors.cyan("db studio")} Open table browser in browser`);
50
54
  log(` ${colors.cyan("db query")} Run a SQL query ${colors.dim("(--branch <id> to target a branch)")}`);
51
55
  log();
@@ -121,6 +125,12 @@ async function main() {
121
125
  case "usage":
122
126
  await usage(rest);
123
127
  break;
128
+ case "link":
129
+ await link(rest);
130
+ break;
131
+ case "unlink":
132
+ unlink();
133
+ break;
124
134
  // Auth
125
135
  case "login":
126
136
  await login();
package/dist/link.d.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Project linking — Vercel/Neon-style. A linked directory carries its project
3
+ * (and optionally its branch) in `.batadata/project.json`, so an agent no longer
4
+ * has to thread `--project <id>` through every invocation.
5
+ *
6
+ * ── Resolution precedence (the ONE rule, used everywhere) ─────────────────
7
+ * 1. an explicit `--project` / `--branch` flag (per-invocation intent)
8
+ * 2. `.batadata/project.json`, discovered by walking UP from CWD like git (per-directory intent)
9
+ * 3. `defaultProject` in `~/.batarc` (per-machine fallback)
10
+ *
11
+ * Flags always win, so linking never silently overrides an explicit request.
12
+ * The precedence itself lives in the pure `pickProjectId` / `pickBranchId`
13
+ * helpers (unit-testable without fs), and `resolveProjectId` / `resolveBranchId`
14
+ * compose them with the on-disk sources.
15
+ */
16
+ export declare const LINK_DIR = ".batadata";
17
+ export declare const LINK_FILE = "project.json";
18
+ export interface LinkFile {
19
+ projectId: string;
20
+ /** Optional pinned branch (set by `bata db branch checkout`). */
21
+ branchId?: string | null;
22
+ }
23
+ export type ProjectSource = "flag" | "link" | "config" | "none";
24
+ export type BranchSource = "flag" | "link" | "none";
25
+ /**
26
+ * The ordered list of directories to search for a link file, from `startDir`
27
+ * up to (and including) the ceiling — the user's HOME dir or the filesystem
28
+ * root, whichever comes first. Exactly how git bounds its repo discovery.
29
+ *
30
+ * Pure: no fs, no env — just path math — so the walk-up order is unit-testable
31
+ * without ever writing to a real HOME.
32
+ */
33
+ export declare function linkSearchDirs(startDir: string, homeDir: string): string[];
34
+ /**
35
+ * Absolute path to the nearest `.batadata/project.json` at or above `startDir`,
36
+ * or null if none exists within the ceiling. Walks up like git.
37
+ */
38
+ export declare function findLinkFile(startDir?: string, homeDir?: string): string | null;
39
+ /**
40
+ * Read and parse the nearest link file. Returns its absolute path alongside the
41
+ * parsed contents, or null if there's no (valid) link file. A malformed file is
42
+ * treated as absent rather than throwing, so a stray/corrupt file can't wedge
43
+ * every command.
44
+ */
45
+ export declare function readLinkFile(startDir?: string, homeDir?: string): {
46
+ path: string;
47
+ link: LinkFile;
48
+ } | null;
49
+ /**
50
+ * Write `.batadata/project.json` inside `dir` (creating `.batadata/` if needed)
51
+ * and return the absolute path written. `branchId` is normalized to null when
52
+ * absent so the file shape is stable.
53
+ */
54
+ export declare function writeLinkFile(dir: string, link: LinkFile): string;
55
+ /**
56
+ * Delete the nearest link file (and its now-empty `.batadata/` dir, if empty).
57
+ * Returns the path removed, or null if there was nothing to remove. Idempotent.
58
+ */
59
+ export declare function removeLinkFile(startDir?: string, homeDir?: string): string | null;
60
+ /**
61
+ * Pure precedence policy for the project id: flag > linked > config > none.
62
+ * No fs/env access, so the ordering is unit-testable in isolation (mirrors the
63
+ * `pickTeamId` pattern in api.ts).
64
+ */
65
+ export declare function pickProjectId(params: {
66
+ flag?: string;
67
+ linked?: string | null;
68
+ config?: string;
69
+ }): {
70
+ projectId?: string;
71
+ source: ProjectSource;
72
+ };
73
+ /**
74
+ * Pure precedence policy for the branch id: flag > linked > none. There is no
75
+ * config-level default branch, so config isn't a source here.
76
+ */
77
+ export declare function pickBranchId(params: {
78
+ flag?: string;
79
+ linked?: string | null;
80
+ }): {
81
+ branchId?: string;
82
+ source: BranchSource;
83
+ };
84
+ /**
85
+ * Resolve the active project id from all sources in precedence order. `explicit`
86
+ * is an already-parsed `--project` value (or a positional id) — pass it through
87
+ * so a flag always wins over a link file or config default.
88
+ */
89
+ export declare function resolveProjectId(explicit?: string, opts?: {
90
+ startDir?: string;
91
+ homeDir?: string;
92
+ }): {
93
+ projectId?: string;
94
+ source: ProjectSource;
95
+ };
96
+ /**
97
+ * Resolve the active branch id from all sources in precedence order. `explicit`
98
+ * is an already-parsed `--branch` value (id or name); the link file's pinned
99
+ * branch is the fallback.
100
+ */
101
+ export declare function resolveBranchId(explicit?: string, opts?: {
102
+ startDir?: string;
103
+ homeDir?: string;
104
+ }): {
105
+ branchId?: string;
106
+ source: BranchSource;
107
+ };
package/dist/link.js ADDED
@@ -0,0 +1,170 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import { loadConfig } from "./config.js";
5
+ /**
6
+ * Project linking — Vercel/Neon-style. A linked directory carries its project
7
+ * (and optionally its branch) in `.batadata/project.json`, so an agent no longer
8
+ * has to thread `--project <id>` through every invocation.
9
+ *
10
+ * ── Resolution precedence (the ONE rule, used everywhere) ─────────────────
11
+ * 1. an explicit `--project` / `--branch` flag (per-invocation intent)
12
+ * 2. `.batadata/project.json`, discovered by walking UP from CWD like git (per-directory intent)
13
+ * 3. `defaultProject` in `~/.batarc` (per-machine fallback)
14
+ *
15
+ * Flags always win, so linking never silently overrides an explicit request.
16
+ * The precedence itself lives in the pure `pickProjectId` / `pickBranchId`
17
+ * helpers (unit-testable without fs), and `resolveProjectId` / `resolveBranchId`
18
+ * compose them with the on-disk sources.
19
+ */
20
+ export const LINK_DIR = ".batadata";
21
+ export const LINK_FILE = "project.json";
22
+ /** Resolve HOME at call time (env-first) so a redirected HOME — every test does
23
+ * this to avoid touching the developer's real home — is honored. Mirrors the
24
+ * same rule in config.ts. */
25
+ function defaultHome() {
26
+ return process.env.HOME || process.env.USERPROFILE || os.homedir();
27
+ }
28
+ /**
29
+ * The ordered list of directories to search for a link file, from `startDir`
30
+ * up to (and including) the ceiling — the user's HOME dir or the filesystem
31
+ * root, whichever comes first. Exactly how git bounds its repo discovery.
32
+ *
33
+ * Pure: no fs, no env — just path math — so the walk-up order is unit-testable
34
+ * without ever writing to a real HOME.
35
+ */
36
+ export function linkSearchDirs(startDir, homeDir) {
37
+ const dirs = [];
38
+ let dir = path.resolve(startDir);
39
+ const home = path.resolve(homeDir);
40
+ // Guard against a pathological loop; the parent === dir root check is the
41
+ // real terminator.
42
+ for (let i = 0; i < 4096; i++) {
43
+ dirs.push(dir);
44
+ if (dir === home)
45
+ break; // don't search above HOME
46
+ const parent = path.dirname(dir);
47
+ if (parent === dir)
48
+ break; // filesystem root
49
+ dir = parent;
50
+ }
51
+ return dirs;
52
+ }
53
+ /**
54
+ * Absolute path to the nearest `.batadata/project.json` at or above `startDir`,
55
+ * or null if none exists within the ceiling. Walks up like git.
56
+ */
57
+ export function findLinkFile(startDir = process.cwd(), homeDir = defaultHome()) {
58
+ for (const dir of linkSearchDirs(startDir, homeDir)) {
59
+ const candidate = path.join(dir, LINK_DIR, LINK_FILE);
60
+ if (fs.existsSync(candidate))
61
+ return candidate;
62
+ }
63
+ return null;
64
+ }
65
+ /**
66
+ * Read and parse the nearest link file. Returns its absolute path alongside the
67
+ * parsed contents, or null if there's no (valid) link file. A malformed file is
68
+ * treated as absent rather than throwing, so a stray/corrupt file can't wedge
69
+ * every command.
70
+ */
71
+ export function readLinkFile(startDir = process.cwd(), homeDir = defaultHome()) {
72
+ const filePath = findLinkFile(startDir, homeDir);
73
+ if (!filePath)
74
+ return null;
75
+ try {
76
+ const raw = fs.readFileSync(filePath, "utf-8");
77
+ const parsed = JSON.parse(raw);
78
+ if (!parsed || typeof parsed.projectId !== "string" || !parsed.projectId) {
79
+ return null;
80
+ }
81
+ return { path: filePath, link: parsed };
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ /**
88
+ * Write `.batadata/project.json` inside `dir` (creating `.batadata/` if needed)
89
+ * and return the absolute path written. `branchId` is normalized to null when
90
+ * absent so the file shape is stable.
91
+ */
92
+ export function writeLinkFile(dir, link) {
93
+ const linkDir = path.join(path.resolve(dir), LINK_DIR);
94
+ fs.mkdirSync(linkDir, { recursive: true });
95
+ const filePath = path.join(linkDir, LINK_FILE);
96
+ const body = {
97
+ projectId: link.projectId,
98
+ branchId: link.branchId ?? null,
99
+ };
100
+ fs.writeFileSync(filePath, JSON.stringify(body, null, 2) + "\n", "utf-8");
101
+ return filePath;
102
+ }
103
+ /**
104
+ * Delete the nearest link file (and its now-empty `.batadata/` dir, if empty).
105
+ * Returns the path removed, or null if there was nothing to remove. Idempotent.
106
+ */
107
+ export function removeLinkFile(startDir = process.cwd(), homeDir = defaultHome()) {
108
+ const filePath = findLinkFile(startDir, homeDir);
109
+ if (!filePath)
110
+ return null;
111
+ try {
112
+ fs.unlinkSync(filePath);
113
+ // Best-effort cleanup of an empty .batadata dir.
114
+ const linkDir = path.dirname(filePath);
115
+ if (path.basename(linkDir) === LINK_DIR && fs.readdirSync(linkDir).length === 0) {
116
+ fs.rmdirSync(linkDir);
117
+ }
118
+ }
119
+ catch {
120
+ // Already gone / racing removal — unlink is best-effort.
121
+ }
122
+ return filePath;
123
+ }
124
+ /**
125
+ * Pure precedence policy for the project id: flag > linked > config > none.
126
+ * No fs/env access, so the ordering is unit-testable in isolation (mirrors the
127
+ * `pickTeamId` pattern in api.ts).
128
+ */
129
+ export function pickProjectId(params) {
130
+ if (params.flag)
131
+ return { projectId: params.flag, source: "flag" };
132
+ if (params.linked)
133
+ return { projectId: params.linked, source: "link" };
134
+ if (params.config)
135
+ return { projectId: params.config, source: "config" };
136
+ return { source: "none" };
137
+ }
138
+ /**
139
+ * Pure precedence policy for the branch id: flag > linked > none. There is no
140
+ * config-level default branch, so config isn't a source here.
141
+ */
142
+ export function pickBranchId(params) {
143
+ if (params.flag)
144
+ return { branchId: params.flag, source: "flag" };
145
+ if (params.linked)
146
+ return { branchId: params.linked, source: "link" };
147
+ return { source: "none" };
148
+ }
149
+ /**
150
+ * Resolve the active project id from all sources in precedence order. `explicit`
151
+ * is an already-parsed `--project` value (or a positional id) — pass it through
152
+ * so a flag always wins over a link file or config default.
153
+ */
154
+ export function resolveProjectId(explicit, opts = {}) {
155
+ const linked = readLinkFile(opts.startDir, opts.homeDir)?.link.projectId ?? null;
156
+ return pickProjectId({
157
+ flag: explicit,
158
+ linked,
159
+ config: loadConfig().defaultProject,
160
+ });
161
+ }
162
+ /**
163
+ * Resolve the active branch id from all sources in precedence order. `explicit`
164
+ * is an already-parsed `--branch` value (id or name); the link file's pinned
165
+ * branch is the fallback.
166
+ */
167
+ export function resolveBranchId(explicit, opts = {}) {
168
+ const linked = readLinkFile(opts.startDir, opts.homeDir)?.link.branchId ?? null;
169
+ return pickBranchId({ flag: explicit, linked });
170
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"