FN-8576: add global quiet flag to CLI
Add a global quiet mode that suppresses informational CLI stdout without hiding requested results. - Parse --quiet/-q and FUSION_QUIET with command and output exemptions. - Route result output and interactive prompts around the reversible stdout gate. - Document the flag and cover quiet output, prompts, and argument parsing. Files changed: .changeset/fn-8576-cli-quiet-flag.md | 7 ++ docs/cli-reference.md | 8 ++ packages/cli/src/__tests__/bin.test.ts | 15 +++ packages/cli/src/__tests__/cli-quiet-mode.test.ts | 63 +++++++++++++ .../__tests__/cli-quiet-prompt-surfaces.test.ts | 33 +++++++ packages/cli/src/bin.ts | 39 ++++++-- packages/cli/src/commands/experiment-finalize.ts | 3 +- packages/cli/src/commands/git.ts | 17 ++-- packages/cli/src/commands/goals.ts | 4 +- packages/cli/src/commands/mission.ts | 8 +- packages/cli/src/commands/node.ts | 4 +- packages/cli/src/commands/onboard.ts | 4 +- packages/cli/src/commands/org-import.ts | 5 +- packages/cli/src/commands/plugin.ts | 14 ++- packages/cli/src/commands/port-prompt.ts | 4 +- packages/cli/src/commands/project.ts | 6 +- packages/cli/src/commands/research.ts | 3 +- packages/cli/src/commands/task.ts | 101 +++++++++++--------- packages/cli/src/commands/update.ts | 3 +- packages/cli/src/commands/workflow.ts | 13 +-- packages/cli/src/output.ts | 104 +++++++++++++++++++++ packages/cli/src/project-resolver.ts | 20 ++-- 22 files changed, 388 insertions(+), 90 deletions(-) Fusion-Task-Id: FN-8576 Fusion-Task-Lineage: 2493d6d5-bbd9-4fc2-b903-950457ef30b0 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8576-cli-quiet-flag.md
Normal file
7
.changeset/fn-8576-cli-quiet-flag.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add a quiet CLI mode that hides informational stdout chatter.
|
||||
category: feature
|
||||
dev: Adds the output.ts quiet seam and FUSION_QUIET environment control.
|
||||
@@ -99,8 +99,16 @@ fn <command> <subcommand> [options]
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--project <name>`, `-P <name>` | Target a specific registered project. |
|
||||
| `--quiet`, `-q` | Suppress informational stdout chatter for non-interactive commands. |
|
||||
| `FUSION_QUIET=1` | Enable quiet mode from the environment (`1`, `true`, `yes`, or `on`). |
|
||||
| `--help`, `-h` | Show help output. |
|
||||
|
||||
### Quiet mode
|
||||
|
||||
`fn --quiet <command>` (or `FUSION_QUIET=1 fn <command>`) suppresses console chatter and raw stdout progress writers without changing exit codes. Stderr, every interactive prompt, and command output whose stdout is the machine-consumable result (such as IDs, paths, and exported payloads) always remain visible. An explicit `--quiet` flag wins over the environment.
|
||||
|
||||
Quiet suppression is disabled for `--json`, help/version requests (including delegated subcommand help), and the live `serve`, `daemon`, `dashboard`, `desktop`, `chat`, and Ink dashboard-TUI surfaces. These commands still accept either quiet flag and strip it before command routing.
|
||||
|
||||
### Project resolution order
|
||||
|
||||
When `--project` is not supplied, Fusion resolves project context in this order:
|
||||
|
||||
@@ -334,6 +334,21 @@ async function runBin(args: string[]) {
|
||||
}
|
||||
|
||||
describe("bin command routing and fallbacks", () => {
|
||||
it("keeps an absent quiet flag undefined while stripping either quiet spelling", async () => {
|
||||
const previous = process.env.FUSION_CLI_SKIP_MAIN;
|
||||
process.env.FUSION_CLI_SKIP_MAIN = "1";
|
||||
try {
|
||||
const { extractGlobalProjectFlag } = await import("../bin.ts?quiet-parser");
|
||||
expect(extractGlobalProjectFlag(["task", "list"]).quiet).toBeUndefined();
|
||||
expect(extractGlobalProjectFlag(["task", "--quiet", "list"]).cleanedArgs).toEqual(["task", "list"]);
|
||||
expect(extractGlobalProjectFlag(["serve", "-q", "--port", "0"]).cleanedArgs).toEqual(["serve", "--port", "0"]);
|
||||
expect(extractGlobalProjectFlag(["--quiet", "serve", "--project", "name"]).cleanedArgs).toEqual(["serve", "--project", "name"]);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.FUSION_CLI_SKIP_MAIN;
|
||||
else process.env.FUSION_CLI_SKIP_MAIN = previous;
|
||||
}
|
||||
});
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
|
||||
63
packages/cli/src/__tests__/cli-quiet-mode.test.ts
Normal file
63
packages/cli/src/__tests__/cli-quiet-mode.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let output: typeof import("../output.js") | undefined;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
|
||||
async function loadOutput() {
|
||||
vi.resetModules();
|
||||
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((() => true) as typeof process.stdout.write);
|
||||
output = await import("../output.js");
|
||||
return output;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
output?.resetQuietMode();
|
||||
output?.uninstallQuietGate();
|
||||
stdoutSpy?.mockRestore();
|
||||
output = undefined;
|
||||
stdoutSpy = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("CLI quiet output seam", () => {
|
||||
it("resolves presence-preserving flag and environment precedence", async () => {
|
||||
const seam = await loadOutput();
|
||||
expect(seam.resolveQuietMode({ flag: true })).toBe(true);
|
||||
expect(seam.resolveQuietMode({ flag: false, env: "true" })).toBe(false);
|
||||
expect(seam.resolveQuietMode({ env: "YES" })).toBe(true);
|
||||
expect(seam.resolveQuietMode({})).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses gated chatter while preserving stderr, results, prompts, and write callbacks", async () => {
|
||||
const seam = await loadOutput();
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((() => true) as typeof process.stderr.write);
|
||||
const callback = vi.fn();
|
||||
seam.setQuietMode(true);
|
||||
seam.installQuietGate();
|
||||
console.log("chatter");
|
||||
console.info("chatter");
|
||||
expect(process.stdout.write("progress", callback)).toBe(true);
|
||||
console.error("error");
|
||||
process.stderr.write("stderr");
|
||||
seam.result("result");
|
||||
seam.promptOutputStream().write("question");
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(stdoutSpy).toHaveBeenCalledTimes(2);
|
||||
expect(errorSpy).toHaveBeenCalledWith("error");
|
||||
expect(stderrSpy).toHaveBeenCalledWith("stderr");
|
||||
});
|
||||
|
||||
it("is idempotent and returns stdout to normal after disable", async () => {
|
||||
const seam = await loadOutput();
|
||||
seam.setQuietMode(true);
|
||||
seam.installQuietGate();
|
||||
const firstWrite = process.stdout.write;
|
||||
seam.installQuietGate();
|
||||
expect(process.stdout.write).toBe(firstWrite);
|
||||
seam.setQuietMode(false);
|
||||
console.log("visible");
|
||||
process.stdout.write("visible");
|
||||
expect(stdoutSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
33
packages/cli/src/__tests__/cli-quiet-prompt-surfaces.test.ts
Normal file
33
packages/cli/src/__tests__/cli-quiet-prompt-surfaces.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const cliRoot = join(import.meta.dirname, "..");
|
||||
const exemptSources = new Set([join(cliRoot, "commands", "chat.ts")]);
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.name === "__tests__") return [];
|
||||
return entry.isDirectory() ? sourceFiles(path) : entry.name.endsWith(".ts") ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe("CLI quiet prompt and result source contracts", () => {
|
||||
it("does not construct a non-exempt readline prompt with gated stdout", () => {
|
||||
for (const path of sourceFiles(cliRoot)) {
|
||||
if (exemptSources.has(path)) continue;
|
||||
const source = readFileSync(path, "utf8");
|
||||
if (!source.includes("createInterface")) continue;
|
||||
expect(source, path).not.toMatch(/output:\s*process\.stdout/);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps all audited result writers attached to the output seam", () => {
|
||||
for (const file of ["task.ts", "org-import.ts", "workflow.ts", "research.ts", "experiment-finalize.ts", "update.ts"]) {
|
||||
const source = readFileSync(join(cliRoot, "commands", file), "utf8");
|
||||
expect(source, file).toMatch(/import\s*\{[^}]*\bresult\b[^}]*\}\s*from\s*["']\.\.\/output\.js["']/);
|
||||
expect(source, file).toMatch(/(?:result|outputResult)\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { Readable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { installQuietGate, resolveQuietMode, setQuietMode, uninstallQuietGate } from "./output.js";
|
||||
|
||||
// @ts-expect-error -- Bun-only global; undefined in Node
|
||||
const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles;
|
||||
@@ -511,6 +512,7 @@ Options:
|
||||
--labels, -L <labels> Comma-separated label filter for import
|
||||
--interactive, -i Interactive mode for issue selection
|
||||
--help, -h Show this help
|
||||
--quiet, -q Suppress informational stdout output
|
||||
|
||||
Columns: triage, todo, in-progress, in-review, done, archived
|
||||
Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv, xml
|
||||
@@ -520,18 +522,29 @@ export function extractGlobalProjectFlag(argv: string[]): {
|
||||
cleanedArgs: string[];
|
||||
projectName?: string;
|
||||
skipOnboarding: boolean;
|
||||
quiet?: boolean;
|
||||
} {
|
||||
const command = argv[0];
|
||||
if (command === "serve" || command === "daemon") {
|
||||
return { cleanedArgs: [...argv], skipOnboarding: false };
|
||||
}
|
||||
|
||||
// FNXC:CliQuietMode 2026-07-16-01:00: Serve/daemon keep their legacy
|
||||
// pass-through argv contract even when a global quiet flag precedes them.
|
||||
const command = argv.find((arg) => arg !== "--quiet" && arg !== "-q");
|
||||
const isServeOrDaemon = command === "serve" || command === "daemon";
|
||||
const cleanedArgs: string[] = [];
|
||||
let projectName: string | undefined;
|
||||
let skipOnboarding = false;
|
||||
// FNXC:CliQuietMode 2026-07-16-00:00: `undefined` preserves flag absence so
|
||||
// FUSION_QUIET can participate in resolution; never collapse it to false.
|
||||
let quiet: boolean | undefined;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--quiet" || arg === "-q") {
|
||||
quiet = true;
|
||||
continue;
|
||||
}
|
||||
if (isServeOrDaemon) {
|
||||
cleanedArgs.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--project" || arg === "-P") {
|
||||
if (projectName) {
|
||||
throw new Error("Duplicate --project flag. Specify a project only once.");
|
||||
@@ -551,7 +564,7 @@ export function extractGlobalProjectFlag(argv: string[]): {
|
||||
cleanedArgs.push(arg);
|
||||
}
|
||||
|
||||
return { cleanedArgs, projectName, skipOnboarding };
|
||||
return { cleanedArgs, projectName, skipOnboarding, quiet };
|
||||
}
|
||||
|
||||
function getFlagValue(args: string[], flag: string): string | undefined {
|
||||
@@ -655,7 +668,19 @@ function readOwnCliVersion(): string | undefined {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { cleanedArgs: args, projectName, skipOnboarding } = extractGlobalProjectFlag(process.argv.slice(2));
|
||||
const { cleanedArgs: args, projectName, skipOnboarding, quiet } = extractGlobalProjectFlag(process.argv.slice(2));
|
||||
const hasJsonFlag = args.includes("--json");
|
||||
const hasHelpOrVersionFlag = args.some((arg) => ["--help", "-h", "--version", "-v"].includes(arg));
|
||||
const selectedCommand = !args[0] || args[0].startsWith("-") ? "dashboard" : args[0];
|
||||
const isExemptCommand = ["serve", "daemon", "dashboard", "desktop", "chat"].includes(selectedCommand);
|
||||
// FNXC:CliQuietMode 2026-07-16-00:00: Recompute effective state on every
|
||||
// invocation. JSON and help/version are requested results; live commands,
|
||||
// including the bare/dashboard Ink TUI path, must retain their UI output.
|
||||
const effectiveQuiet = resolveQuietMode({ flag: quiet, env: process.env.FUSION_QUIET })
|
||||
&& !hasJsonFlag && !hasHelpOrVersionFlag && !isExemptCommand;
|
||||
setQuietMode(effectiveQuiet);
|
||||
if (effectiveQuiet) installQuietGate();
|
||||
else uninstallQuietGate();
|
||||
|
||||
// Print version and exit before any application imports. This is what the
|
||||
// dashboard's CLI Binary panel probes via `<bin> --version`; without an
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type FinalizePlanOverride,
|
||||
} from "@fusion/engine";
|
||||
import { closeProjectStore, resolveProject, type ProjectContext } from "../project-context.js";
|
||||
import { result } from "../output.js";
|
||||
|
||||
interface ExperimentFinalizeOptions {
|
||||
sessionId: string;
|
||||
@@ -34,7 +35,7 @@ const EXIT_CODES = new Map<string, number>([
|
||||
]);
|
||||
|
||||
function printJson(payload: unknown): void {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
result(JSON.stringify(payload, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function printPlan(plan: Awaited<ReturnType<ExperimentFinalizeService["previewPlan"]>>): void {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { promisify } from "node:util";
|
||||
const execAsync = promisify(exec);
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { resolveProjectPathOnly } from "../project-context.js";
|
||||
import { promptOutputStream, result } from "../output.js";
|
||||
|
||||
/**
|
||||
* FNXC:CliBoardMutation 2026-07-09-00:00:
|
||||
@@ -322,11 +323,15 @@ export async function runGitPull(options: { skipConfirm?: boolean; projectName?:
|
||||
|
||||
// Warn about uncommitted changes
|
||||
if (status.isDirty && !options.skipConfirm) {
|
||||
console.log();
|
||||
console.log(" ⚠ Warning: You have uncommitted changes.");
|
||||
console.log(` Branch: ${status.branch}`);
|
||||
/*
|
||||
* FNXC:CliQuietMode 2026-07-16-00:00:
|
||||
* The dirty-worktree warning identifies the consequence the user is being
|
||||
* asked to confirm, so it must bypass quiet-mode chatter suppression.
|
||||
*/
|
||||
result(`\n ⚠ Warning: You have uncommitted changes.\n Branch: ${status.branch}\n`);
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */
|
||||
output: promptOutputStream() });
|
||||
const answer = await rl.question(" Continue with pull? [y/N] ");
|
||||
rl.close();
|
||||
|
||||
@@ -394,7 +399,7 @@ export async function runGitPush(options: { skipConfirm?: boolean; projectName?:
|
||||
// Confirmation prompt
|
||||
if (!options.skipConfirm) {
|
||||
console.log();
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
const answer = await rl.question(` Push branch ${status.branch} to remote? [Y/n] `);
|
||||
rl.close();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import type { GoalCitationSurface } from "@fusion/core";
|
||||
import { getStore } from "../project-resolver.js";
|
||||
import { promptOutputStream } from "../output.js";
|
||||
|
||||
type GoalStatusFilter = "active" | "archived" | "all";
|
||||
|
||||
@@ -38,7 +39,8 @@ async function promptForTitleAndDescription(
|
||||
let description: string | undefined;
|
||||
|
||||
if (!title) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */
|
||||
output: promptOutputStream() });
|
||||
title = await rl.question("Goal title: ");
|
||||
|
||||
if (!title?.trim()) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@fusion/core";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { resolveProjectStore } from "../project-resolver.js";
|
||||
import { promptOutputStream } from "../output.js";
|
||||
|
||||
// ── Status Labels for Display ───────────────────────────────────────────────
|
||||
|
||||
@@ -105,7 +106,8 @@ async function promptForTitleAndDescription(
|
||||
if (!title) {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
/* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */
|
||||
output: promptOutputStream(),
|
||||
});
|
||||
title = await rl.question(titlePrompt);
|
||||
|
||||
@@ -431,7 +433,7 @@ export async function runMissionDelete(
|
||||
if (!force) {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
output: promptOutputStream(),
|
||||
});
|
||||
const answer = await rl.question(
|
||||
`Are you sure you want to delete ${id}: "${mission.title}"? [y/N] `,
|
||||
@@ -614,7 +616,7 @@ export async function runFeatureAdd(
|
||||
if (!title) {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
output: promptOutputStream(),
|
||||
});
|
||||
title = await rl.question("Feature title: ");
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CentralCore, type NodeConfig } from "@fusion/core";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { promptOutputStream } from "../output.js";
|
||||
|
||||
const GREEN = "\x1b[32m";
|
||||
const RED = "\x1b[31m";
|
||||
@@ -362,7 +363,8 @@ export async function runNodeDisconnect(
|
||||
}
|
||||
|
||||
if (!options.force) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */
|
||||
output: promptOutputStream() });
|
||||
const answer = await rl.question(`Disconnect node '${node.name}'? [y/N] `);
|
||||
rl.close();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createInterface } from "node:readline";
|
||||
import { CentralCore, GlobalSettingsStore, getDefaultCentralDbPath } from "@fusion/core";
|
||||
import { createFusionAuthStorage, createFusionModelRegistry } from "@fusion/engine";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import { promptOutputStream } from "../output.js";
|
||||
import { runInit } from "./init.js";
|
||||
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { getModelRegistryModelsPath } from "./auth-paths.js";
|
||||
@@ -37,7 +38,8 @@ interface PromptSession {
|
||||
}
|
||||
|
||||
function createPromptSession(input: NodeJS.ReadableStream = process.stdin): PromptSession {
|
||||
const rl = createInterface({ input, output: process.stdout });
|
||||
const rl = createInterface({ input, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */
|
||||
output: promptOutputStream() });
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { AgentStore, AutomationStore, RoutineStore, createTaskStoreForBackend, materializeOrgBundle, type OrgBundle } from "@fusion/core";
|
||||
import { resolveProjectPathOnly } from "../project-context.js";
|
||||
import { result } from "../output.js";
|
||||
|
||||
export async function runOrgImport(file: string, options: { project?: string; dryRun?: boolean; collisionMode?: "skip" | "suffix" } = {}): Promise<void> {
|
||||
const rootDir = options.project ? await resolveProjectPathOnly(options.project) : process.cwd();
|
||||
@@ -11,8 +12,8 @@ export async function runOrgImport(file: string, options: { project?: string; dr
|
||||
const agents = new AgentStore({ rootDir: `${rootDir}/.fusion`, asyncLayer: boot.taskStore.asyncLayer! });
|
||||
try {
|
||||
await agents.init();
|
||||
const result = await materializeOrgBundle({ projectRoot: rootDir, agentStore: agents, routineStore: new RoutineStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), automationStore: new AutomationStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), settingsStore: boot.taskStore }, bundle, { dryRun: options.dryRun, collisionMode: options.collisionMode });
|
||||
const imported = await materializeOrgBundle({ projectRoot: rootDir, agentStore: agents, routineStore: new RoutineStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), automationStore: new AutomationStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), settingsStore: boot.taskStore }, bundle, { dryRun: options.dryRun, collisionMode: options.collisionMode });
|
||||
console.log(options.dryRun ? "Organization import dry-run:" : "Organization imported:");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
result(JSON.stringify(imported, null, 2) + "\n");
|
||||
} finally { agents.close(); await boot.shutdown(); }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { readFile, stat } from "node:fs/promises";
|
||||
import * as readline from "node:readline";
|
||||
import { PluginStore, PluginLoader, validatePluginManifest, resolveGlobalDir, CentralCore } from "@fusion/core";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import { promptOutputStream, result } from "../output.js";
|
||||
|
||||
export interface BuiltinPluginCatalogEntry {
|
||||
id: string;
|
||||
@@ -432,15 +433,18 @@ export async function runPluginUninstall(
|
||||
|
||||
// Confirm unless force
|
||||
if (!options?.force) {
|
||||
console.log();
|
||||
console.log(` Uninstall "${plugin.name}" globally?`);
|
||||
console.log(" This removes it for all projects.");
|
||||
console.log();
|
||||
/*
|
||||
* FNXC:CliQuietMode 2026-07-16-00:00:
|
||||
* This label describes the irreversible scope of the following prompt,
|
||||
* so it remains visible while quiet mode suppresses ordinary status logs.
|
||||
*/
|
||||
result(`\n Uninstall "${plugin.name}" globally?\n This removes it for all projects.\n\n`);
|
||||
|
||||
const response = await new Promise<string>((resolve) => {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
/* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */
|
||||
output: promptOutputStream(),
|
||||
});
|
||||
rl.question(" Continue? [y/N] ", (answer: string) => {
|
||||
rl.close();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { promptOutputStream } from "../output.js";
|
||||
|
||||
/**
|
||||
* Prompt the user for a port number interactively.
|
||||
@@ -26,7 +27,8 @@ export function promptForPort(
|
||||
return new Promise((resolve, reject) => {
|
||||
const rl = createInterface({
|
||||
input,
|
||||
output: process.stdout,
|
||||
/* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */
|
||||
output: promptOutputStream(),
|
||||
});
|
||||
|
||||
// Handle Ctrl+C during prompt
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { resolve, isAbsolute, relative, basename, join } from "node:path";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { promptOutputStream } from "../output.js";
|
||||
import { detectProjectFromCwd, setDefaultProject } from "../project-context.js";
|
||||
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
|
||||
import { retryOnLock } from "../lock-retry.js";
|
||||
@@ -313,7 +314,8 @@ export async function runProjectAdd(
|
||||
|
||||
// Interactive mode if name or path not provided
|
||||
if (!projectName || !projectPath || options.interactive) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */
|
||||
output: promptOutputStream() });
|
||||
|
||||
// Get path if not provided
|
||||
if (!projectPath) {
|
||||
@@ -494,7 +496,7 @@ export async function runProjectRemove(name: string, options: ProjectRemoveOptio
|
||||
}
|
||||
|
||||
if (!options.force) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
const answer = await rl.question(`Unregister project '${project.name}'? [y/N] `);
|
||||
rl.close();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { result } from "../output.js";
|
||||
import { join, resolve } from "node:path";
|
||||
import {
|
||||
RESEARCH_EXPORT_FORMATS,
|
||||
@@ -142,7 +143,7 @@ function printRun(run: ResearchRun): void {
|
||||
}
|
||||
|
||||
function jsonOut(payload: unknown): void {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
result(JSON.stringify(payload, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function handleError(error: unknown): never {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
runGhJsonAsync,
|
||||
} from "@fusion/core/gh-cli";
|
||||
import { resolveProject, createLocalStore, closeProjectStore, type ProjectContext } from "../project-context.js";
|
||||
import { promptOutputStream, result as outputResult } from "../output.js";
|
||||
import { findNodeByNameOrId } from "./node.js";
|
||||
import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.js";
|
||||
|
||||
@@ -382,7 +383,8 @@ async function runCliNearDuplicateCheck(args: {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */
|
||||
output: promptOutputStream() });
|
||||
try {
|
||||
const answer = (await rl.question("Create anyway? [y/N]: ")).trim().toLowerCase();
|
||||
if (answer === "y" || answer === "yes") {
|
||||
@@ -400,7 +402,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
|
||||
let description = descriptionArg;
|
||||
|
||||
if (!description) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
description = await rl.question("Task description: ");
|
||||
rl.close();
|
||||
}
|
||||
@@ -506,9 +508,9 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
|
||||
console.log(` Project: ${context.projectName}`);
|
||||
}
|
||||
if (linkedExisting) {
|
||||
console.log(` ✓ Linked existing ${resolvedTask.id}: ${label}`);
|
||||
outputResult(` ✓ Linked existing ${resolvedTask.id}: ${label}\n`);
|
||||
} else {
|
||||
console.log(` ✓ Created ${resolvedTask.id}: ${label}`);
|
||||
outputResult(` ✓ Created ${resolvedTask.id}: ${label}\n`);
|
||||
}
|
||||
console.log(` Column: ${resolvedTask.column}`);
|
||||
if (resolvedTask.dependencies.length > 0) {
|
||||
@@ -1204,7 +1206,7 @@ export async function runTaskRefine(id: string, feedbackArg?: string, projectNam
|
||||
// store resolution, since this is not a board call.
|
||||
let feedback = feedbackArg;
|
||||
if (feedback === undefined) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
feedback = await rl.question("What needs to be refined? ");
|
||||
rl.close();
|
||||
}
|
||||
@@ -1420,7 +1422,7 @@ export async function runTaskDelete(id: string, force?: boolean, allowResurrecti
|
||||
|
||||
// Prompt for confirmation unless force is used
|
||||
if (!force) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
const answer = await rl.question(`Are you sure you want to delete ${id}? [y/N] `);
|
||||
rl.close();
|
||||
|
||||
@@ -1495,8 +1497,13 @@ export async function runTaskImportGitHubInteractive(
|
||||
return;
|
||||
}
|
||||
|
||||
// Display issues with numbers
|
||||
console.log(` Found ${issues.length} issues:\n`);
|
||||
/*
|
||||
* FNXC:CliQuietMode 2026-07-16-00:00:
|
||||
* Issue choices and validation feedback are required context for this
|
||||
* selection prompt. Preserve them through the output seam when quiet mode
|
||||
* gates informational stdout.
|
||||
*/
|
||||
outputResult(` Found ${issues.length} issues:\n\n`);
|
||||
for (let i = 0; i < issues.length; i++) {
|
||||
const issue = issues[i];
|
||||
const importedTask = existingTasks.find((task) => dashboard.isGitHubIssueAlreadyImported(task, {
|
||||
@@ -1506,13 +1513,13 @@ export async function runTaskImportGitHubInteractive(
|
||||
sourceUrl: issue.html_url,
|
||||
}));
|
||||
const status = importedTask ? ` [Imported as ${importedTask.id}]` : "";
|
||||
console.log(` ${i + 1}. #${issue.number} ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "…" : ""}${status}`);
|
||||
outputResult(` ${i + 1}. #${issue.number} ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "…" : ""}${status}\n`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
outputResult("\n");
|
||||
|
||||
// Create readline interface for interactive selection
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
|
||||
let selectedIndices: number[] = [];
|
||||
let validInput = false;
|
||||
@@ -1531,13 +1538,13 @@ export async function runTaskImportGitHubInteractive(
|
||||
.filter((n) => !isNaN(n));
|
||||
|
||||
if (nums.length === 0) {
|
||||
console.log(" Please enter at least one number or 'all'");
|
||||
outputResult(" Please enter at least one number or 'all'\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
const outOfRange = nums.filter((n) => n < 1 || n > issues.length);
|
||||
if (outOfRange.length > 0) {
|
||||
console.log(` Invalid selection: ${outOfRange.join(", ")} (range: 1-${issues.length})`);
|
||||
outputResult(` Invalid selection: ${outOfRange.join(", ")} (range: 1-${issues.length})\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1595,7 +1602,7 @@ export async function runTaskImportGitHubInteractive(
|
||||
}));
|
||||
|
||||
const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "");
|
||||
console.log(` ✓ Created ${task.id}: ${label}`);
|
||||
outputResult(` ✓ Created ${task.id}: ${label}\n`);
|
||||
existingTasks.push(task);
|
||||
created++;
|
||||
}
|
||||
@@ -1768,7 +1775,7 @@ export async function runTaskImportFromGitHub(
|
||||
}));
|
||||
|
||||
const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "");
|
||||
console.log(` ✓ Created ${task.id}: ${label}`);
|
||||
outputResult(` ✓ Created ${task.id}: ${label}\n`);
|
||||
existingTasks.push(task);
|
||||
created++;
|
||||
}
|
||||
@@ -1841,7 +1848,7 @@ export async function runTaskImportFromGitLab(
|
||||
await retryBoardCall(context, task.id, "log entry", () => store.logEntry(task.id, resource === "merge-requests" ? "Imported merge request from GitLab" : "Imported from GitLab", item.webUrl));
|
||||
existingTasks.push(task);
|
||||
created += 1;
|
||||
console.log(` ✓ Created ${task.id}: ${task.title}`);
|
||||
outputResult(` ✓ Created ${task.id}: ${task.title}\n`);
|
||||
}
|
||||
console.log(`\n ✓ Imported ${created} GitLab tasks${skipped > 0 ? ` (${skipped} skipped)` : ""}\n`);
|
||||
} finally {
|
||||
@@ -1853,7 +1860,7 @@ export async function runTaskComment(id: string, message?: string, author = "use
|
||||
// Interactive prompt runs BEFORE store resolution (not a board call).
|
||||
let text = message;
|
||||
if (text === undefined) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
text = await rl.question("Comment: ");
|
||||
rl.close();
|
||||
}
|
||||
@@ -1910,7 +1917,7 @@ export async function runTaskSteer(id: string, message?: string, projectName?: s
|
||||
// resolution (not a board call).
|
||||
let text = message;
|
||||
if (text === undefined) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
text = await rl.question("Message: ");
|
||||
rl.close();
|
||||
}
|
||||
@@ -1975,14 +1982,20 @@ function clearThinking(): void {
|
||||
}
|
||||
|
||||
/** Prompt for text (multi-line) question */
|
||||
async function promptText(question: PlanningQuestion): Promise<string> {
|
||||
console.log(`\n ${question.question}`);
|
||||
if (question.description) {
|
||||
console.log(` ${question.description}`);
|
||||
}
|
||||
console.log(" (Enter your response. Type DONE on its own line when finished):\n");
|
||||
// FNXC:CliQuietMode 2026-07-16-01:00: Planning questions, choices, and
|
||||
// validation are required prompt UI and must bypass the global stdout gate.
|
||||
function writePromptUi(text: string): void {
|
||||
outputResult(text);
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
async function promptText(question: PlanningQuestion): Promise<string> {
|
||||
writePromptUi(`\n ${question.question}\n`);
|
||||
if (question.description) {
|
||||
writePromptUi(` ${question.description}\n`);
|
||||
}
|
||||
writePromptUi(" (Enter your response. Type DONE on its own line when finished):\n\n");
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
const lines: string[] = [];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
@@ -2003,11 +2016,11 @@ async function promptText(question: PlanningQuestion): Promise<string> {
|
||||
|
||||
/** Prompt for single_select question */
|
||||
async function promptSingleSelect(question: PlanningQuestion): Promise<string> {
|
||||
console.log(`\n ${question.question}`);
|
||||
writePromptUi(`\n ${question.question}\n`);
|
||||
if (question.description) {
|
||||
console.log(` ${question.description}`);
|
||||
writePromptUi(` ${question.description}\n`);
|
||||
}
|
||||
console.log();
|
||||
writePromptUi("\n");
|
||||
|
||||
if (!question.options || question.options.length === 0) {
|
||||
throw new Error("Single select question has no options");
|
||||
@@ -2015,13 +2028,13 @@ async function promptSingleSelect(question: PlanningQuestion): Promise<string> {
|
||||
|
||||
for (let i = 0; i < question.options.length; i++) {
|
||||
const opt = question.options[i];
|
||||
console.log(` ${i + 1}. ${opt.label}`);
|
||||
writePromptUi(` ${i + 1}. ${opt.label}\n`);
|
||||
if (opt.description) {
|
||||
console.log(` ${opt.description}`);
|
||||
writePromptUi(` ${opt.description}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
|
||||
while (true) {
|
||||
const answer = await rl.question("\n Select (1-" + question.options.length + "): ");
|
||||
@@ -2032,17 +2045,17 @@ async function promptSingleSelect(question: PlanningQuestion): Promise<string> {
|
||||
return question.options[num - 1].id;
|
||||
}
|
||||
|
||||
console.log(` Invalid selection. Please enter a number between 1 and ${question.options.length}`);
|
||||
writePromptUi(` Invalid selection. Please enter a number between 1 and ${question.options.length}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Prompt for multi_select question */
|
||||
async function promptMultiSelect(question: PlanningQuestion): Promise<string[]> {
|
||||
console.log(`\n ${question.question}`);
|
||||
writePromptUi(`\n ${question.question}\n`);
|
||||
if (question.description) {
|
||||
console.log(` ${question.description}`);
|
||||
writePromptUi(` ${question.description}\n`);
|
||||
}
|
||||
console.log(" (Enter comma-separated numbers, e.g., 1,3,4):\n");
|
||||
writePromptUi(" (Enter comma-separated numbers, e.g., 1,3,4):\n\n");
|
||||
|
||||
if (!question.options || question.options.length === 0) {
|
||||
throw new Error("Multi select question has no options");
|
||||
@@ -2050,13 +2063,13 @@ async function promptMultiSelect(question: PlanningQuestion): Promise<string[]>
|
||||
|
||||
for (let i = 0; i < question.options.length; i++) {
|
||||
const opt = question.options[i];
|
||||
console.log(` ${i + 1}. ${opt.label}`);
|
||||
writePromptUi(` ${i + 1}. ${opt.label}\n`);
|
||||
if (opt.description) {
|
||||
console.log(` ${opt.description}`);
|
||||
writePromptUi(` ${opt.description}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
|
||||
while (true) {
|
||||
const answer = await rl.question("\n Select (comma-separated): ");
|
||||
@@ -2066,13 +2079,13 @@ async function promptMultiSelect(question: PlanningQuestion): Promise<string[]>
|
||||
.filter((n) => !isNaN(n));
|
||||
|
||||
if (nums.length === 0) {
|
||||
console.log(" Please select at least one option");
|
||||
writePromptUi(" Please select at least one option\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
const invalid = nums.filter((n) => n < 1 || n > question.options!.length);
|
||||
if (invalid.length > 0) {
|
||||
console.log(` Invalid selection: ${invalid.join(", ")}. Range: 1-${question.options.length}`);
|
||||
writePromptUi(` Invalid selection: ${invalid.join(", ")}. Range: 1-${question.options.length}\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2083,12 +2096,12 @@ async function promptMultiSelect(question: PlanningQuestion): Promise<string[]>
|
||||
|
||||
/** Prompt for confirm question */
|
||||
async function promptConfirm(question: PlanningQuestion): Promise<boolean> {
|
||||
console.log(`\n ${question.question}`);
|
||||
writePromptUi(`\n ${question.question}\n`);
|
||||
if (question.description) {
|
||||
console.log(` ${question.description}`);
|
||||
writePromptUi(` ${question.description}\n`);
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
const answer = await rl.question("\n [Y/n]: ");
|
||||
rl.close();
|
||||
|
||||
@@ -2180,7 +2193,7 @@ export async function runTaskPlan(
|
||||
|
||||
// If no initial plan, prompt interactively
|
||||
if (!initialPlan && !resumeSessionId) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
console.log("\n Let's plan your task. What would you like to accomplish?\n");
|
||||
initialPlan = await rl.question(" Describe your idea: ");
|
||||
rl.close();
|
||||
@@ -2392,7 +2405,7 @@ export async function runTaskPlan(
|
||||
// Ask for confirmation (unless --yes flag)
|
||||
let confirmed = yesFlag;
|
||||
if (!yesFlag) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
const answer = await rl.question(" Create this task? [Y/n]: ");
|
||||
rl.close();
|
||||
const trimmed = answer.trim().toLowerCase();
|
||||
@@ -2413,7 +2426,7 @@ export async function runTaskPlan(
|
||||
createTaskFromPlanSession(sessionId, store, { baseBranch: baseBranch?.trim() || undefined }));
|
||||
|
||||
console.log();
|
||||
console.log(` ${alreadyCreated ? "✓ Task already created from this plan:" : "✓ Created"} ${task.id}: ${task.title || task.description.slice(0, 60)}${task.description.length > 60 ? "…" : ""}`);
|
||||
outputResult(` ${alreadyCreated ? "✓ Task already created from this plan:" : "✓ Created"} ${task.id}: ${task.title || task.description.slice(0, 60)}${task.description.length > 60 ? "…" : ""}\n`);
|
||||
console.log(` Column: ${task.column ?? "triage"}`);
|
||||
if (task.dependencies.length > 0) {
|
||||
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
|
||||
@@ -2430,7 +2443,7 @@ export async function runTaskPlan(
|
||||
*/
|
||||
if (!yesFlag) {
|
||||
// FNXC:PlanningMultiTask 2026-07-24-03:20: close the interface on every path, including thrown prompts (review finding — a leaked readline keeps the process alive).
|
||||
const rlContinue = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rlContinue = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
let wantsMore = false;
|
||||
let focus = "";
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { result } from "../output.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
import { dirname, resolve } from "node:path";
|
||||
@@ -323,7 +324,7 @@ function printStatus(status: UpdateStatus, checkOnly: boolean): void {
|
||||
}
|
||||
|
||||
function printJson(status: UpdateStatus): void {
|
||||
console.log(JSON.stringify(status));
|
||||
result(JSON.stringify(status) + "\n");
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
||||
import { createTaskStoreForBackend, type TaskStore } from "@fusion/core";
|
||||
import { validateWorkflowIrDryRun } from "@fusion/engine";
|
||||
import { cleanupProjectResolution, getStore } from "../project-resolver.js";
|
||||
import { result } from "../output.js";
|
||||
|
||||
export interface RunWorkflowValidateOptions {
|
||||
workflowId?: string;
|
||||
@@ -30,7 +31,7 @@ async function resolveStore(projectName?: string): Promise<OwnedWorkflowStore> {
|
||||
}
|
||||
|
||||
function printJsonAndExit(payload: unknown, code: number): never {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
result(JSON.stringify(payload, null, 2) + "\n");
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ export async function runWorkflowValidate(opts: RunWorkflowValidateOptions): Pro
|
||||
const store = owned.store;
|
||||
/* FNXC:PostgresCliLifecycle 2026-07-14-19:10: Workflow validation must await the exact startup owner before any process exit; a finally block is insufficient because process.exit skips pending cleanup. */
|
||||
const exitWithStore = async (payload: unknown | undefined, code: number): Promise<never> => {
|
||||
if (payload !== undefined) console.log(JSON.stringify(payload, null, 2));
|
||||
if (payload !== undefined) result(JSON.stringify(payload, null, 2) + "\n");
|
||||
const current = owned;
|
||||
owned = undefined;
|
||||
await current!.shutdown();
|
||||
@@ -81,14 +82,14 @@ export async function runWorkflowValidate(opts: RunWorkflowValidateOptions): Pro
|
||||
ir = def.ir;
|
||||
}
|
||||
|
||||
const result = await validateWorkflowIrDryRun(store, ir, false);
|
||||
if (opts.json) return await exitWithStore(result.valid ? { valid: true } : { valid: false, errors: result.errors }, result.valid ? 0 : 1);
|
||||
if (result.valid) {
|
||||
const validation = await validateWorkflowIrDryRun(store, ir, false);
|
||||
if (opts.json) return await exitWithStore(validation.valid ? { valid: true } : { valid: false, errors: validation.errors }, validation.valid ? 0 : 1);
|
||||
if (validation.valid) {
|
||||
console.log("✓ Workflow IR is valid. No workflow was created or mutated.");
|
||||
return await exitWithStore(undefined, 0);
|
||||
}
|
||||
console.error("✗ Workflow IR is invalid:");
|
||||
for (const error of result.errors) console.error(` - ${error.message}`);
|
||||
for (const error of validation.errors) console.error(` - ${error.message}`);
|
||||
return await exitWithStore(undefined, 1);
|
||||
} finally {
|
||||
const current = owned;
|
||||
|
||||
104
packages/cli/src/output.ts
Normal file
104
packages/cli/src/output.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Writable } from "node:stream";
|
||||
|
||||
/*
|
||||
* FNXC:CliQuietMode 2026-07-16-00:00:
|
||||
* Quiet mode suppresses console and raw stdout chatter while preserving stderr,
|
||||
* interactive prompts, and result-bearing output. JSON, help/version, and
|
||||
* exempt live surfaces (including the Ink TUI) resolve quiet off per invocation.
|
||||
* The presence-preserving flag beats the environment, and this dynamic gate is
|
||||
* reversible so repeated in-process CLI runs never leak output state.
|
||||
*/
|
||||
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleInfo = console.info;
|
||||
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
|
||||
let quietMode = false;
|
||||
let gateInstalled = false;
|
||||
|
||||
function isTruthyEnvFlag(value: string | undefined): boolean {
|
||||
if (value === undefined) return false;
|
||||
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function resolveQuietMode({ flag, env }: { flag?: boolean; env?: string }): boolean {
|
||||
return flag === undefined ? isTruthyEnvFlag(env) : flag;
|
||||
}
|
||||
|
||||
export function setQuietMode(enabled: boolean): void {
|
||||
quietMode = enabled;
|
||||
}
|
||||
|
||||
export function isQuietMode(): boolean {
|
||||
return quietMode;
|
||||
}
|
||||
|
||||
export function resetQuietMode(): void {
|
||||
quietMode = false;
|
||||
}
|
||||
|
||||
/** Install one dynamic stdout gate; reinstallation deliberately never double-wraps. */
|
||||
export function installQuietGate(): void {
|
||||
if (gateInstalled) return;
|
||||
gateInstalled = true;
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
if (!isQuietMode()) originalConsoleLog(...args);
|
||||
};
|
||||
console.info = (...args: unknown[]) => {
|
||||
if (!isQuietMode()) originalConsoleInfo(...args);
|
||||
};
|
||||
|
||||
process.stdout.write = ((chunk: unknown, encoding?: unknown, callback?: unknown): boolean => {
|
||||
if (!isQuietMode()) {
|
||||
return originalStdoutWrite(chunk as never, encoding as never, callback as never);
|
||||
}
|
||||
const done = typeof encoding === "function" ? encoding : callback;
|
||||
if (typeof done === "function") {
|
||||
done();
|
||||
}
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
}
|
||||
|
||||
export function uninstallQuietGate(): void {
|
||||
if (!gateInstalled) return;
|
||||
console.log = originalConsoleLog;
|
||||
console.info = originalConsoleInfo;
|
||||
process.stdout.write = originalStdoutWrite as typeof process.stdout.write;
|
||||
gateInstalled = false;
|
||||
}
|
||||
|
||||
/** Write machine-consumable command output without consulting the quiet gate. */
|
||||
export function result(text: string): void {
|
||||
originalStdoutWrite(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Readline needs stdout's terminal metadata as well as its writer. This proxy
|
||||
* bypasses quiet only for prompt rendering while forwarding all other stream
|
||||
* behaviour to the real stdout instance.
|
||||
*/
|
||||
export function promptOutputStream(): NodeJS.WritableStream {
|
||||
const proxy = new Writable({
|
||||
write(chunk, encoding, callback) {
|
||||
originalStdoutWrite(chunk, encoding, callback);
|
||||
},
|
||||
});
|
||||
|
||||
return new Proxy(proxy, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "columns" || property === "rows" || property === "isTTY") {
|
||||
return Reflect.get(process.stdout, property);
|
||||
}
|
||||
if (property === "write") {
|
||||
return (chunk: unknown, encoding?: unknown, callback?: unknown) =>
|
||||
originalStdoutWrite(chunk as never, encoding as never, callback as never);
|
||||
}
|
||||
const value = Reflect.get(target, property, receiver);
|
||||
if (value !== undefined) return typeof value === "function" ? value.bind(target) : value;
|
||||
const stdoutValue = Reflect.get(process.stdout, property);
|
||||
return typeof stdoutValue === "function" ? stdoutValue.bind(process.stdout) : stdoutValue;
|
||||
},
|
||||
}) as unknown as NodeJS.WritableStream;
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { basename, dirname, join, normalize, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { promptOutputStream, result as outputResult } from "./output.js";
|
||||
import {
|
||||
CentralCore,
|
||||
createTaskStoreForBackend,
|
||||
@@ -144,11 +145,14 @@ async function promptProjectSelection(
|
||||
projects: RegisteredProject[],
|
||||
message = "Select a project:"
|
||||
): Promise<RegisteredProject> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */
|
||||
output: promptOutputStream() });
|
||||
|
||||
console.log(`\n ${message}`);
|
||||
// FNXC:CliQuietMode 2026-07-16-01:00: Selection labels and validation are
|
||||
// part of the interactive prompt, so they bypass quiet mode with readline.
|
||||
outputResult(`\n ${message}\n`);
|
||||
for (let i = 0; i < projects.length; i++) {
|
||||
console.log(` ${i + 1}. ${projects[i].name} (${projects[i].path})`);
|
||||
outputResult(` ${i + 1}. ${projects[i].name} (${projects[i].path})\n`);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
@@ -160,7 +164,7 @@ async function promptProjectSelection(
|
||||
return projects[num - 1];
|
||||
}
|
||||
|
||||
console.log(` Invalid selection. Please enter a number between 1 and ${projects.length}`);
|
||||
outputResult(` Invalid selection. Please enter a number between 1 and ${projects.length}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +172,7 @@ async function promptProjectSelection(
|
||||
* Prompt for yes/no confirmation.
|
||||
*/
|
||||
async function promptConfirm(message: string, defaultYes = false): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
const prompt = defaultYes ? "[Y/n]" : "[y/N]";
|
||||
const answer = await rl.question(` ${message} ${prompt}: `);
|
||||
rl.close();
|
||||
@@ -307,7 +311,7 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
|
||||
const shouldRegister = await promptConfirm("Register this project now?", true);
|
||||
|
||||
if (shouldRegister) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
const defaultName = basename(fusionDir) || "unnamed";
|
||||
const name = await rl.question(` Project name [${defaultName}]: `);
|
||||
rl.close();
|
||||
@@ -741,7 +745,7 @@ export async function registerProjectInteractive(
|
||||
// Determine project name
|
||||
let name = options.name;
|
||||
if (!name && interactive) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
const suggested = suggestProjectName(absPath);
|
||||
const input = await rl.question(` Project name [${suggested}]: `);
|
||||
rl.close();
|
||||
@@ -793,7 +797,7 @@ export async function registerProjectInteractive(
|
||||
try {
|
||||
let prefix = suggestTaskPrefix(name);
|
||||
if (interactive) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
|
||||
const prefixInput = await rl.question(`\n Task prefix [${prefix}]: `);
|
||||
rl.close();
|
||||
const rawPrefix = prefixInput.trim().toUpperCase().replace(/[^A-Z]/g, "");
|
||||
|
||||
Reference in New Issue
Block a user