Skip to content

Commit e63711f

Browse files
srousseyclaudeCopilot
authored
feat: wire up --dry-run flag to prevent all writes (#75)
* feat: wire up --dry-run flag to prevent all writes When --dry-run is passed, the CLI now runs commands normally (fetches, processing) but suppresses all write operations: - Database writes: ReadOnlyTabularStorage wraps real storage, forwarding reads and no-oping puts/deletes/setupDatabase - File downloads: BootstrapDownloadTask logs what it would download and exits early - Cache writes: SecFetchFileOutputCache.saveOutput skips file writes - Init command: shows what config/dirs would be created without writing The SEC_DRY_RUN DI token is set in the preAction hook from the global --dry-run option, and createStorage wraps repositories automatically. runCommand prints a "Dry run" banner when active. https://claude.ai/code/session_01G4XE6pxyLRhiM2wAepVTZj * refactor: consolidate dry-run checks onto isDryRun() helper; fix init command banner (#77) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent c461792 commit e63711f

10 files changed

Lines changed: 220 additions & 13 deletions

File tree

src/cli/groups/init.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import { createInterface } from "readline";
33
import { existsSync, mkdirSync, writeFileSync } from "fs";
44
import { resolve } from "path";
55
import { homedir } from "os";
6+
import { globalServiceRegistry } from "@workglow/util";
67
import { setupAllDatabases } from "../../config/setupAllDatabases";
8+
import { parseGlobalOptions } from "../GlobalOptions";
79
import { runCommand } from "../runCommand";
10+
import { SEC_DRY_RUN } from "../../config/tokens";
811

912
export interface InitConfig {
1013
readonly dbType: "sqlite" | "postgres";
@@ -62,6 +65,9 @@ export function addInitCommand(parent: Command): void {
6265
.command("init")
6366
.description("Interactive first-run setup wizard")
6467
.action(async () => {
68+
const dryRun = parseGlobalOptions(parent).dryRun;
69+
globalServiceRegistry.registerInstance(SEC_DRY_RUN, dryRun);
70+
6571
await runCommand(async () => {
6672
const envPath = resolve(process.cwd(), ".env.local");
6773

@@ -122,6 +128,16 @@ export function addInitCommand(parent: Command): void {
122128
};
123129

124130
const envContent = buildEnvConfig(config);
131+
132+
if (dryRun) {
133+
console.log(`Would write ${envPath}:`);
134+
console.log(envContent);
135+
console.log(`Would create directory: ${dbFolder}`);
136+
console.log(`Would create directory: ${rawDataFolder}`);
137+
console.log("Would create database tables.");
138+
return;
139+
}
140+
125141
writeFileSync(envPath, envContent, "utf-8");
126142
console.log(`Wrote ${envPath}`);
127143

src/cli/isDryRun.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { globalServiceRegistry } from "@workglow/util";
2+
import { SEC_DRY_RUN } from "../config/tokens";
3+
4+
export function isDryRun(): boolean {
5+
return globalServiceRegistry.has(SEC_DRY_RUN) && globalServiceRegistry.get(SEC_DRY_RUN);
6+
}

src/cli/runCommand.test.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,23 @@ describe("runCommand", () => {
5151
}
5252
});
5353

54-
it("skips execution in dry-run mode", async () => {
54+
it("prints dry-run banner when SEC_DRY_RUN is set", async () => {
55+
const { globalServiceRegistry } = await import("@workglow/util");
56+
const { SEC_DRY_RUN } = await import("../config/tokens");
57+
globalServiceRegistry.registerInstance(SEC_DRY_RUN, true);
5558
const action = mock(() => Promise.resolve());
56-
const code = await runCommand(action, { dryRun: true });
57-
expect(code).toBe(0);
58-
expect(process.exitCode).toBe(0);
59-
expect(action).not.toHaveBeenCalled();
59+
const origLog = console.log;
60+
const logs: string[] = [];
61+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
62+
try {
63+
const code = await runCommand(action);
64+
expect(code).toBe(0);
65+
expect(action).toHaveBeenCalledTimes(1);
66+
expect(logs.some((l) => l.includes("Dry run"))).toBe(true);
67+
} finally {
68+
console.log = origLog;
69+
globalServiceRegistry.registerInstance(SEC_DRY_RUN, false);
70+
}
6071
});
6172

6273
it("sets process.exitCode to 0 on success", async () => {

src/cli/runCommand.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
11
import { statusMessage } from "./output/Progress";
2+
import { isDryRun } from "./isDryRun";
23

34
export interface RunCommandOptions {
4-
readonly dryRun?: boolean;
55
readonly onError?: (error: unknown) => void;
66
}
77

88
export async function runCommand(
99
action: () => Promise<void>,
1010
options?: RunCommandOptions
1111
): Promise<number> {
12-
if (options?.dryRun) {
13-
process.exitCode = 0;
14-
return 0;
15-
}
16-
1712
try {
13+
if (isDryRun()) {
14+
console.log(statusMessage("info", "Dry run — no data will be written"));
15+
}
1816
await action();
1917
process.exitCode = 0;
2018
return 0;

src/commands/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
*/
66

77
import type { Command } from "commander";
8+
import { globalServiceRegistry } from "@workglow/util";
89
import { EnvToDI } from "../config/EnvToDI";
910
import { SecJobQueueClient, SecJobQueueServer, SecJobQueueStorage } from "../fetch/SecJobQueue";
1011
import { getTaskQueueRegistry } from "@workglow/task-graph";
1112
import { DefaultDI } from "../config/DefaultDI";
13+
import { SEC_DRY_RUN } from "../config/tokens";
14+
import { parseGlobalOptions } from "../cli/GlobalOptions";
1215
import { addBootstrapCommands } from "../cli/groups/bootstrap";
1316
import { addSyncCommand } from "../cli/groups/sync";
1417
import { addUpdateCommands } from "../cli/groups/update";
@@ -26,6 +29,9 @@ export const AddCommands = (program: Command): void => {
2629
if (diInitialized) return;
2730
diInitialized = true;
2831

32+
const globalOpts = parseGlobalOptions(program);
33+
globalServiceRegistry.registerInstance(SEC_DRY_RUN, globalOpts.dryRun);
34+
2935
EnvToDI();
3036
DefaultDI();
3137

src/config/createStorage.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { globalServiceRegistry } from "@workglow/util";
1111
import { SEC_DB_TYPE } from "./tokens";
1212
import { getDb } from "../util/db";
1313
import { getPgPool } from "../util/pg";
14+
import { ReadOnlyTabularStorage } from "../storage/ReadOnlyTabularStorage";
15+
import { isDryRun } from "../cli/isDryRun";
1416

1517
export function createStorage<
1618
Schema extends DataPortSchemaObject,
@@ -23,8 +25,19 @@ export function createStorage<
2325
indexes?: readonly (keyof Entity | readonly (keyof Entity)[])[]
2426
): ITabularStorage<Schema, PrimaryKeyNames, Entity> {
2527
const dbType = globalServiceRegistry.get(SEC_DB_TYPE);
28+
let storage: ITabularStorage<Schema, PrimaryKeyNames, Entity>;
2629
if (dbType === "postgres") {
27-
return new PostgresTabularStorage(getPgPool(), table, schema, primaryKeyNames, indexes as any);
30+
storage = new PostgresTabularStorage(getPgPool(), table, schema, primaryKeyNames, indexes as any);
31+
} else {
32+
storage = new SqliteTabularStorage(getDb(), table, schema, primaryKeyNames, indexes as any);
2833
}
29-
return new SqliteTabularStorage(getDb(), table, schema, primaryKeyNames, indexes as any);
34+
35+
if (isDryRun()) {
36+
return new ReadOnlyTabularStorage(storage) as unknown as ITabularStorage<
37+
Schema,
38+
PrimaryKeyNames,
39+
Entity
40+
>;
41+
}
42+
return storage;
3043
}

src/config/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export const SEC_PG_PORT = createServiceToken<string>("sec.pg.port");
1616
export const SEC_PG_USER = createServiceToken<string>("sec.pg.user");
1717
export const SEC_PG_PASSWORD = createServiceToken<string>("sec.pg.password");
1818
export const SEC_PG_DATABASE = createServiceToken<string>("sec.pg.database");
19+
export const SEC_DRY_RUN = createServiceToken<boolean>("sec.dry.run");

src/fetch/SecFetchFileOutputCache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { FetchUrlTaskOutput } from "@workglow/tasks";
99
import { mkdirSync } from "node:fs";
1010
import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
1111
import path from "node:path";
12+
import { isDryRun } from "../cli/isDryRun";
1213
import { secDate } from "../util/parseDate";
1314
import { YYYYdMMdDD } from "../util/parseDate";
1415

@@ -66,6 +67,9 @@ export class SecFetchFileOutputCache extends TaskOutputRepository {
6667
* @param output The task output to save
6768
*/
6869
async saveOutput(taskType: string, input: TaskInput, output: TaskOutput): Promise<void> {
70+
if (isDryRun()) {
71+
return;
72+
}
6973
const filePath = path.join(this.folderPath, this.inputToFileName(input));
7074
await mkdir(path.dirname(filePath), { recursive: true });
7175
await writeFile(filePath, this.outputSerializer(output, input.response_type as string), {
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type {
2+
ITabularStorage,
3+
TabularEventName,
4+
TabularEventListener,
5+
TabularEventParameters,
6+
TabularChangePayload,
7+
TabularSubscribeOptions,
8+
DeleteSearchCriteria,
9+
SearchCriteria,
10+
QueryOptions,
11+
} from "@workglow/storage";
12+
import type { DataPortSchemaObject, FromSchema, TypedArraySchemaOptions } from "@workglow/util";
13+
import type {
14+
AutoGeneratedKeys,
15+
InsertEntity,
16+
SimplifyPrimaryKey,
17+
} from "@workglow/storage";
18+
19+
/**
20+
* Wraps an {@link ITabularStorage} and silently no-ops all write operations.
21+
* Read operations (get, getAll, query, getBulk, records, pages, size) are
22+
* forwarded to the underlying storage so that dry-run commands can still
23+
* inspect existing data.
24+
*/
25+
export class ReadOnlyTabularStorage<
26+
Schema extends DataPortSchemaObject,
27+
PrimaryKeyNames extends ReadonlyArray<keyof Schema["properties"]>,
28+
Entity = FromSchema<Schema, TypedArraySchemaOptions>,
29+
PrimaryKey = SimplifyPrimaryKey<Entity, PrimaryKeyNames>,
30+
InsertType = InsertEntity<Entity, AutoGeneratedKeys<Schema>>,
31+
> implements ITabularStorage<Schema, PrimaryKeyNames, Entity, PrimaryKey, InsertType>
32+
{
33+
constructor(
34+
private readonly inner: ITabularStorage<Schema, PrimaryKeyNames, Entity, PrimaryKey, InsertType>
35+
) {}
36+
37+
// ── writes (no-op) ─────────────────────────────────────────────────
38+
39+
async put(_value: InsertType): Promise<Entity> {
40+
return _value as unknown as Entity;
41+
}
42+
43+
async putBulk(values: InsertType[]): Promise<Entity[]> {
44+
return values as unknown as Entity[];
45+
}
46+
47+
async delete(_key: PrimaryKey | Entity): Promise<void> {}
48+
49+
async deleteAll(): Promise<void> {}
50+
51+
async deleteSearch(_criteria: DeleteSearchCriteria<Entity>): Promise<void> {}
52+
53+
async setupDatabase(): Promise<void> {}
54+
55+
// ── reads (forwarded) ──────────────────────────────────────────────
56+
57+
get(key: PrimaryKey): Promise<Entity | undefined> {
58+
return this.inner.get(key);
59+
}
60+
61+
getAll(options?: QueryOptions<Entity>): Promise<Entity[] | undefined> {
62+
return this.inner.getAll(options);
63+
}
64+
65+
size(): Promise<number> {
66+
return this.inner.size();
67+
}
68+
69+
getBulk(offset: number, limit: number): Promise<Entity[] | undefined> {
70+
return this.inner.getBulk(offset, limit);
71+
}
72+
73+
records(pageSize?: number): AsyncGenerator<Entity, void, undefined> {
74+
return this.inner.records(pageSize);
75+
}
76+
77+
pages(pageSize?: number): AsyncGenerator<Entity[], void, undefined> {
78+
return this.inner.pages(pageSize);
79+
}
80+
81+
query(
82+
criteria: SearchCriteria<Entity>,
83+
options?: QueryOptions<Entity>
84+
): Promise<Entity[] | undefined> {
85+
return this.inner.query(criteria, options);
86+
}
87+
88+
// ── events (forwarded) ────────────────────────────────────────────
89+
90+
on<Event extends TabularEventName>(
91+
name: Event,
92+
fn: TabularEventListener<Event, PrimaryKey, Entity>
93+
): void {
94+
this.inner.on(name, fn);
95+
}
96+
97+
off<Event extends TabularEventName>(
98+
name: Event,
99+
fn: TabularEventListener<Event, PrimaryKey, Entity>
100+
): void {
101+
this.inner.off(name, fn);
102+
}
103+
104+
emit<Event extends TabularEventName>(
105+
name: Event,
106+
...args: TabularEventParameters<Event, PrimaryKey, Entity>
107+
): void {
108+
this.inner.emit(name, ...args);
109+
}
110+
111+
once<Event extends TabularEventName>(
112+
name: Event,
113+
fn: TabularEventListener<Event, PrimaryKey, Entity>
114+
): void {
115+
this.inner.once(name, fn);
116+
}
117+
118+
waitOn<Event extends TabularEventName>(
119+
name: Event
120+
): Promise<TabularEventParameters<Event, PrimaryKey, Entity>> {
121+
return this.inner.waitOn(name);
122+
}
123+
124+
subscribeToChanges(
125+
callback: (change: TabularChangePayload<Entity>) => void,
126+
options?: TabularSubscribeOptions
127+
): () => void {
128+
return this.inner.subscribeToChanges(callback, options);
129+
}
130+
131+
// ── lifecycle ─────────────────────────────────────────────────────
132+
133+
destroy(): void {
134+
this.inner.destroy();
135+
}
136+
137+
[Symbol.dispose](): void {
138+
this.inner[Symbol.dispose]();
139+
}
140+
141+
[Symbol.asyncDispose](): Promise<void> {
142+
return this.inner[Symbol.asyncDispose]();
143+
}
144+
}

src/task/bootstrap/BootstrapDownloadTask.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { resolve, join, sep } from "node:path";
1111
import { mkdirSync, rmSync } from "node:fs";
1212
import { SEC_RAW_DATA_FOLDER } from "../../config/tokens";
1313
import { SecUserAgent } from "../../config/Constants";
14+
import { isDryRun } from "../../cli/isDryRun";
1415

1516
export type BootstrapDownloadTaskInput = {
1617
readonly url: string;
@@ -49,6 +50,8 @@ export class BootstrapDownloadTask extends Task<
4950
input: BootstrapDownloadTaskInput,
5051
context: IExecuteContext
5152
): Promise<BootstrapDownloadTaskOutput> {
53+
const dryRun = isDryRun();
54+
5255
const rawDataFolder = globalServiceRegistry.get(SEC_RAW_DATA_FOLDER);
5356
const targetDir = resolve(rawDataFolder, input.targetFolder);
5457

@@ -60,6 +63,11 @@ export class BootstrapDownloadTask extends Task<
6063
);
6164
}
6265

66+
if (dryRun) {
67+
console.log(`Would download ${input.url} to ${targetDir}`);
68+
return { success: true };
69+
}
70+
6371
mkdirSync(targetDir, { recursive: true });
6472

6573
const zipPath = join(rawDataFolder, `${input.targetFolder}.zip`);

0 commit comments

Comments
 (0)