@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 +47 -0
- package/dist/api.d.ts +26 -0
- package/dist/api.js +57 -15
- package/dist/commands/connect.js +4 -2
- package/dist/commands/db.d.ts +28 -1
- package/dist/commands/db.js +177 -17
- package/dist/commands/link.d.ts +2 -0
- package/dist/commands/link.js +138 -0
- package/dist/commands/projects.d.ts +8 -0
- package/dist/commands/projects.js +18 -6
- package/dist/commands/schema.js +5 -4
- package/dist/commands/status.js +3 -2
- package/dist/config.d.ts +8 -0
- package/dist/config.js +15 -0
- package/dist/index.js +10 -0
- package/dist/link.d.ts +107 -0
- package/dist/link.js +170 -0
- package/package.json +1 -1
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
|
|
82
|
-
if (saved)
|
|
83
|
-
return saved;
|
|
84
|
-
if (_cachedTeamId)
|
|
116
|
+
if (_resolved)
|
|
85
117
|
return _cachedTeamId;
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
/**
|
package/dist/commands/connect.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/commands/db.d.ts
CHANGED
|
@@ -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(
|
|
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>;
|
package/dist/commands/db.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
246
|
+
export async function branchCreate(args = []) {
|
|
187
247
|
const jsonMode = isJsonMode();
|
|
188
248
|
const token = requireToken();
|
|
189
249
|
const config = loadConfig();
|
|
190
|
-
const projectId =
|
|
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: {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
658
|
+
return branchCreate(args.slice(2));
|
|
501
659
|
if (action === "delete")
|
|
502
660
|
return branchDelete(args[2]);
|
|
503
|
-
|
|
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,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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/schema.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { api, apiError } from "../api.js";
|
|
3
|
-
import { requireToken,
|
|
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
|
-
|
|
94
|
-
const projectId =
|
|
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.", "
|
|
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)
|
package/dist/commands/status.js
CHANGED
|
@@ -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:
|
|
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
|
+
}
|