Skip to content

Commit 1919b84

Browse files
authored
Merge 70764b0 into 2039672
2 parents 2039672 + 70764b0 commit 1919b84

14 files changed

Lines changed: 313 additions & 70 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/commands.md

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Adding & Modifying CLI Commands
22

3-
**Keywords:** command, factory pattern, CLIContext, isNonInteractive, runCommand, runTask, spinner, theming, chalk, program.ts, register, banner, intro, outro
3+
**Keywords:** command, factory pattern, CLIContext, isNonInteractive, isJsonMode, runCommand, runTask, spinner, theming, chalk, program.ts, register, banner, intro, outro, json, --json, data, piping
44

55
Commands live in `src/cli/commands/<domain>/`. They use a **factory pattern** with dependency injection via `CLIContext`.
66

@@ -79,11 +79,13 @@ await runCommand(myAction, { fullBanner: true, requireAuth: true }, context);
7979
export interface CLIContext {
8080
errorReporter: ErrorReporter;
8181
isNonInteractive: boolean;
82+
isJsonMode: boolean;
8283
}
8384
```
8485

8586
- Created once in `runCLI()` at startup
8687
- `isNonInteractive` is `true` when stdin/stdout are not a TTY (e.g., CI, piped output, AI agents). Use it to skip interactive prompts, browser opens, and animations.
88+
- `isJsonMode` is set by the global `--json` flag via a `preAction` hook. Commands don't need to check it directly -- `runCommand` handles all mode-switching.
8789
- Passed to `createProgram(context)`, which passes it to each command factory
8890
- Commands pass it to `runCommand()` for error reporting integration
8991

@@ -195,6 +197,73 @@ export function getMyCommand(context: CLIContext): Command {
195197

196198
Access `command.args` for positional arguments and `command.opts()` for options inside the hook. See `secrets/set.ts` and `project/create.ts` for real examples.
197199

200+
## JSON Mode (`--json`)
201+
202+
The CLI supports a global `--json` flag that outputs structured JSON instead of human-readable text. This enables piping output to tools like `jq`:
203+
204+
```bash
205+
base44 logs --function my-fn --json | jq '.logs[] | .message'
206+
```
207+
208+
### How it works
209+
210+
When `--json` is set, `runCommand` mutes `process.stdout.write` before calling `commandFn()`. This silences all clack output (intro, outro, `log.*`, spinners) automatically. Only the serialized `result.data` is written to stdout. Errors are written to stderr as JSON.
211+
212+
### Adding JSON support to a command
213+
214+
Return a `data` field from your action. Always use a top-level object (wrap arrays):
215+
216+
```typescript
217+
async function listAction(): Promise<RunCommandResult> {
218+
const items = await fetchItems();
219+
220+
return {
221+
outroMessage: `Found ${items.length} items`,
222+
stdout: formatItems(items), // human mode
223+
data: { items }, // json mode: { "items": [...] }
224+
};
225+
}
226+
```
227+
228+
- `data` is `Record<string, unknown>` -- always an object, never a raw array
229+
- If `data` is not set, `runCommand` falls back to `{ "message": outroMessage }`
230+
- Commands need zero changes to be silenced -- the stdout muting handles `log.*` and spinners
231+
232+
### Error format
233+
234+
On error in JSON mode, a JSON object is written to stderr:
235+
236+
```json
237+
{
238+
"error": true,
239+
"code": "API_ERROR",
240+
"message": "Request failed with status 500",
241+
"hints": [{ "message": "Check your network connection" }]
242+
}
243+
```
244+
245+
### Interactive commands
246+
247+
Commands with interactive prompts (`select`, `confirm`, `text`) should validate required flags in a `preAction` hook. This fires before `runCommand` (no wasted auth/API calls) and works for both `--json` and piped/CI sessions:
248+
249+
```typescript
250+
export function getMyCommand(context: CLIContext): Command {
251+
return new Command("my-cmd")
252+
.option("-y, --yes", "Skip confirmation prompt")
253+
.hook("preAction", (command) => {
254+
if (!context.isJsonMode && !context.isNonInteractive) return;
255+
if (!command.opts().yes) {
256+
command.error("Non-interactive mode requires: --yes");
257+
}
258+
})
259+
.action(async (options) => {
260+
await runCommand(() => myAction(options), { requireAuth: true }, context);
261+
});
262+
}
263+
```
264+
265+
List specific missing flags so users know exactly what to add (e.g., `Missing: --project-id <id>, --path <path>`).
266+
198267
## Rules (Command-Specific)
199268

200269
- **Command factory pattern** - Commands export `getXCommand(context)` functions, not static instances

src/cli/commands/project/create.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,23 @@ async function getTemplateById(templateId: string): Promise<Template> {
4545
return template;
4646
}
4747

48-
function validateNonInteractiveFlags(command: Command): void {
49-
const { path } = command.opts<CreateOptions>();
48+
function validateFlags(context: CLIContext) {
49+
return (command: Command): void => {
50+
const opts = command.opts<CreateOptions>();
51+
const name = command.args[0];
5052

51-
if (path && !command.args.length) {
52-
command.error("Non-interactive mode requires all flags: --name, --path");
53-
}
53+
if (opts.path && !(opts.name ?? name)) {
54+
command.error("Non-interactive mode requires all flags: --name, --path");
55+
}
56+
57+
if (context.isJsonMode || context.isNonInteractive) {
58+
if (!(opts.name ?? name) || !opts.path) {
59+
command.error(
60+
"Non-interactive mode requires: <name> and --path <path>",
61+
);
62+
}
63+
}
64+
};
5465
}
5566

5667
async function createInteractive(
@@ -290,7 +301,7 @@ export function getCreateCommand(context: CLIContext): Command {
290301
)
291302
.option("--deploy", "Build and deploy the site")
292303
.option("--no-skills", "Skip AI agent skills installation")
293-
.hook("preAction", validateNonInteractiveFlags)
304+
.hook("preAction", validateFlags(context))
294305
.action(async (name: string | undefined, options: CreateOptions) => {
295306
const isNonInteractive = !!(options.name ?? name) && !!options.path;
296307

@@ -304,7 +315,11 @@ export function getCreateCommand(context: CLIContext): Command {
304315
} else {
305316
await runCommand(
306317
() => createInteractive({ name, ...options }),
307-
{ fullBanner: true, requireAuth: true, requireAppConfig: false },
318+
{
319+
fullBanner: true,
320+
requireAuth: true,
321+
requireAppConfig: false,
322+
},
308323
context,
309324
);
310325
}

src/cli/commands/project/deploy.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,22 @@ export async function deployAction(
120120
return { outroMessage: "App deployed successfully" };
121121
}
122122

123+
function validateNonInteractiveMode(context: CLIContext) {
124+
return (command: Command): void => {
125+
if (!context.isJsonMode && !context.isNonInteractive) return;
126+
if (!command.opts<DeployOptions>().yes) {
127+
command.error("Non-interactive mode requires: --yes");
128+
}
129+
};
130+
}
131+
123132
export function getDeployCommand(context: CLIContext): Command {
124133
return new Command("deploy")
125134
.description(
126135
"Deploy all project resources (entities, functions, agents, connectors, and site)",
127136
)
128137
.option("-y, --yes", "Skip confirmation prompt")
138+
.hook("preAction", validateNonInteractiveMode(context))
129139
.action(async (options: DeployOptions) => {
130140
await runCommand(
131141
() =>

src/cli/commands/project/eject.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,19 @@ async function eject(options: EjectOptions): Promise<RunCommandResult> {
169169
return { outroMessage: "Your new project is set and ready to use" };
170170
}
171171

172+
function validateNonInteractiveMode(context: CLIContext) {
173+
return (command: Command): void => {
174+
if (!context.isJsonMode && !context.isNonInteractive) return;
175+
const opts = command.opts<EjectOptions>();
176+
const missing: string[] = [];
177+
if (!opts.projectId) missing.push("--project-id <id>");
178+
if (!opts.path) missing.push("--path <path>");
179+
if (missing.length > 0) {
180+
command.error(`Non-interactive mode requires: ${missing.join(", ")}`);
181+
}
182+
};
183+
}
184+
172185
export function getEjectCommand(context: CLIContext): Command {
173186
return new Command("eject")
174187
.description("Download the code for an existing Base44 project")
@@ -178,6 +191,7 @@ export function getEjectCommand(context: CLIContext): Command {
178191
"Project ID to eject (skips interactive selection)",
179192
)
180193
.option("-y, --yes", "Skip confirmation prompts")
194+
.hook("preAction", validateNonInteractiveMode(context))
181195
.action(async (options: EjectOptions) => {
182196
await runCommand(
183197
() => eject({ ...options, isNonInteractive: context.isNonInteractive }),

src/cli/commands/project/link.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,26 @@ interface LinkOptions {
3535

3636
type LinkAction = "create" | "choose";
3737

38-
function validateNonInteractiveFlags(command: Command): void {
39-
const { create, name, projectId } = command.opts<LinkOptions>();
38+
function validateFlags(context: CLIContext) {
39+
return (command: Command): void => {
40+
const { create, name, projectId } = command.opts<LinkOptions>();
4041

41-
if (create && projectId) {
42-
command.error("--create and --projectId cannot be used together");
43-
}
42+
if (create && projectId) {
43+
command.error("--create and --projectId cannot be used together");
44+
}
4445

45-
if (create && !name) {
46-
command.error("--name is required when using --create");
47-
}
46+
if (create && !name) {
47+
command.error("--name is required when using --create");
48+
}
49+
50+
if (context.isJsonMode || context.isNonInteractive) {
51+
if (!projectId && !create) {
52+
command.error(
53+
"Non-interactive mode requires --projectId <id> or --create --name <name>",
54+
);
55+
}
56+
}
57+
};
4858
}
4959

5060
async function promptForLinkAction(): Promise<LinkAction> {
@@ -260,7 +270,7 @@ export function getLinkCommand(context: CLIContext): Command {
260270
"-p, --projectId <id>",
261271
"Project ID to link to an existing project (skips selection prompt)",
262272
)
263-
.hook("preAction", validateNonInteractiveFlags)
273+
.hook("preAction", validateFlags(context))
264274
.action(async (options: LinkOptions) => {
265275
await runCommand(
266276
() => link(options),

src/cli/commands/project/logs.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ interface LogsOptions {
2121
level?: string;
2222
limit?: string;
2323
order?: string;
24-
json?: boolean;
2524
}
2625

2726
/**
@@ -187,11 +186,11 @@ async function logsAction(options: LogsOptions): Promise<RunCommandResult> {
187186
entries = entries.slice(0, limit);
188187
}
189188

190-
const logsOutput = options.json
191-
? `${JSON.stringify(entries, null, 2)}\n`
192-
: formatLogs(entries);
193-
194-
return { outroMessage: "Fetched logs", stdout: logsOutput };
189+
return {
190+
outroMessage: "Fetched logs",
191+
stdout: formatLogs(entries),
192+
data: { logs: entries },
193+
};
195194
}
196195

197196
export function getLogsCommand(context: CLIContext): Command {

src/cli/commands/site/deploy.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,20 @@ async function deployAction(options: DeployOptions): Promise<RunCommandResult> {
5353
return { outroMessage: `Visit your site at: ${result.appUrl}` };
5454
}
5555

56+
function validateNonInteractiveMode(context: CLIContext) {
57+
return (command: Command): void => {
58+
if (!context.isJsonMode && !context.isNonInteractive) return;
59+
if (!command.opts<DeployOptions>().yes) {
60+
command.error("Non-interactive mode requires: --yes");
61+
}
62+
};
63+
}
64+
5665
export function getSiteDeployCommand(context: CLIContext): Command {
5766
return new Command("deploy")
5867
.description("Deploy built site files to Base44 hosting")
5968
.option("-y, --yes", "Skip confirmation prompt")
69+
.hook("preAction", validateNonInteractiveMode(context))
6070
.action(async (options: DeployOptions) => {
6171
await runCommand(
6272
() =>

src/cli/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ async function runCLI(): Promise<void> {
1414

1515
// Create context for dependency injection
1616
const isNonInteractive = !process.stdin.isTTY || !process.stdout.isTTY;
17-
const context: CLIContext = { errorReporter, isNonInteractive };
17+
const context: CLIContext = {
18+
errorReporter,
19+
isNonInteractive,
20+
isJsonMode: false,
21+
};
1822

1923
// Create program with injected context
2024
const program = createProgram(context);

src/cli/program.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ export function createProgram(context: CLIContext): Command {
2929
)
3030
.version(packageJson.version);
3131

32+
program.option(
33+
"--json",
34+
"Output results as JSON instead of human-readable text",
35+
);
36+
37+
program.hook("preAction", (thisCommand) => {
38+
if (thisCommand.opts().json) {
39+
context.isJsonMode = true;
40+
}
41+
});
42+
3243
program.configureHelp({
3344
sortSubcommands: true,
3445
});

0 commit comments

Comments
 (0)