rowbound 1.0.2
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/LICENSE +21 -0
- package/README.md +258 -0
- package/dist/adapters/adapter.d.ts +1 -0
- package/dist/adapters/adapter.js +1 -0
- package/dist/adapters/sheets/sheets-adapter.d.ts +66 -0
- package/dist/adapters/sheets/sheets-adapter.js +531 -0
- package/dist/cli/config.d.ts +2 -0
- package/dist/cli/config.js +397 -0
- package/dist/cli/env.d.ts +3 -0
- package/dist/cli/env.js +103 -0
- package/dist/cli/format.d.ts +5 -0
- package/dist/cli/format.js +6 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +39 -0
- package/dist/cli/init.d.ts +10 -0
- package/dist/cli/init.js +72 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +212 -0
- package/dist/cli/runs.d.ts +2 -0
- package/dist/cli/runs.js +108 -0
- package/dist/cli/status.d.ts +2 -0
- package/dist/cli/status.js +108 -0
- package/dist/cli/sync.d.ts +2 -0
- package/dist/cli/sync.js +84 -0
- package/dist/cli/watch.d.ts +2 -0
- package/dist/cli/watch.js +348 -0
- package/dist/core/condition.d.ts +25 -0
- package/dist/core/condition.js +66 -0
- package/dist/core/defaults.d.ts +3 -0
- package/dist/core/defaults.js +7 -0
- package/dist/core/engine.d.ts +50 -0
- package/dist/core/engine.js +234 -0
- package/dist/core/env.d.ts +13 -0
- package/dist/core/env.js +72 -0
- package/dist/core/exec.d.ts +24 -0
- package/dist/core/exec.js +134 -0
- package/dist/core/extractor.d.ts +10 -0
- package/dist/core/extractor.js +33 -0
- package/dist/core/http-client.d.ts +32 -0
- package/dist/core/http-client.js +161 -0
- package/dist/core/rate-limiter.d.ts +25 -0
- package/dist/core/rate-limiter.js +64 -0
- package/dist/core/reconcile.d.ts +24 -0
- package/dist/core/reconcile.js +192 -0
- package/dist/core/run-format.d.ts +39 -0
- package/dist/core/run-format.js +201 -0
- package/dist/core/run-state.d.ts +64 -0
- package/dist/core/run-state.js +141 -0
- package/dist/core/run-tracker.d.ts +15 -0
- package/dist/core/run-tracker.js +57 -0
- package/dist/core/safe-compare.d.ts +8 -0
- package/dist/core/safe-compare.js +19 -0
- package/dist/core/shell-escape.d.ts +7 -0
- package/dist/core/shell-escape.js +9 -0
- package/dist/core/tab-resolver.d.ts +17 -0
- package/dist/core/tab-resolver.js +44 -0
- package/dist/core/template.d.ts +32 -0
- package/dist/core/template.js +82 -0
- package/dist/core/types.d.ts +105 -0
- package/dist/core/types.js +2 -0
- package/dist/core/url-guard.d.ts +21 -0
- package/dist/core/url-guard.js +184 -0
- package/dist/core/validator.d.ts +11 -0
- package/dist/core/validator.js +261 -0
- package/dist/core/waterfall.d.ts +26 -0
- package/dist/core/waterfall.js +55 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +16 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +943 -0
- package/package.json +67 -0
package/dist/cli/run.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { SheetsAdapter } from "../adapters/sheets/sheets-adapter.js";
|
|
2
|
+
import { runPipeline } from "../core/engine.js";
|
|
3
|
+
import { buildSafeEnv } from "../core/env.js";
|
|
4
|
+
import { reconcile } from "../core/reconcile.js";
|
|
5
|
+
import { createRunState } from "../core/run-state.js";
|
|
6
|
+
import { createRunTracker } from "../core/run-tracker.js";
|
|
7
|
+
import { bold, dim, error, success, warn } from "./format.js";
|
|
8
|
+
export function registerRun(program) {
|
|
9
|
+
program
|
|
10
|
+
.command("run")
|
|
11
|
+
.description("Run the enrichment pipeline")
|
|
12
|
+
.argument("<sheetId>", "Google Sheets spreadsheet ID")
|
|
13
|
+
.option("--tab <name>", "Sheet tab name", "Sheet1")
|
|
14
|
+
.option("--rows <range>", "Row range to process (e.g. 2-50)")
|
|
15
|
+
.option("--action <id>", "Run only a specific action")
|
|
16
|
+
.option("--dry-run", "Dry run — compute but do not write back", false)
|
|
17
|
+
.option("--json", "Output result as JSON")
|
|
18
|
+
.option("-q, --quiet", "Suppress per-row output, show only final summary")
|
|
19
|
+
.action(async (sheetId, opts) => {
|
|
20
|
+
const adapter = new SheetsAdapter();
|
|
21
|
+
const ref = { spreadsheetId: sheetId, sheetName: opts.tab };
|
|
22
|
+
const jsonMode = opts.json ?? false;
|
|
23
|
+
const quietMode = opts.quiet ?? false;
|
|
24
|
+
/** Log only when not in --json mode */
|
|
25
|
+
const log = (msg) => {
|
|
26
|
+
if (!jsonMode)
|
|
27
|
+
console.log(msg);
|
|
28
|
+
};
|
|
29
|
+
const logErr = (msg) => {
|
|
30
|
+
if (!jsonMode)
|
|
31
|
+
console.error(msg);
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
const config = await adapter.readConfig(ref);
|
|
35
|
+
if (!config) {
|
|
36
|
+
logErr(error("No Rowbound config found.") +
|
|
37
|
+
" Run 'rowbound init <sheetId>' first.");
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// Reconcile column registry (detect renames, track new columns, migrate v1→v2)
|
|
42
|
+
const reconciled = await reconcile(adapter, ref, config);
|
|
43
|
+
if (reconciled.messages.length > 0) {
|
|
44
|
+
log(dim("\u21BB Reconciling columns..."));
|
|
45
|
+
for (const msg of reconciled.messages) {
|
|
46
|
+
log(` ${dim(msg)}`);
|
|
47
|
+
}
|
|
48
|
+
log("");
|
|
49
|
+
}
|
|
50
|
+
if (reconciled.configChanged) {
|
|
51
|
+
await adapter.writeConfig(ref, reconciled.config);
|
|
52
|
+
}
|
|
53
|
+
const tabConfig = reconciled.tabConfig;
|
|
54
|
+
if (tabConfig.actions.length === 0) {
|
|
55
|
+
logErr(error("No actions configured.") +
|
|
56
|
+
" Add actions with 'rowbound config add-action'.");
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (opts.rows && !/^\d+-\d+$/.test(opts.rows)) {
|
|
61
|
+
logErr(`${error("Invalid --rows format.")} Expected e.g. 2-50.`);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (opts.rows) {
|
|
66
|
+
const [startStr, endStr] = opts.rows.split("-");
|
|
67
|
+
const start = parseInt(startStr, 10);
|
|
68
|
+
const end = parseInt(endStr, 10);
|
|
69
|
+
if (start > end) {
|
|
70
|
+
logErr(`${error("Invalid --rows range.")} Start (${start}) must be <= end (${end}).`);
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (opts.action &&
|
|
76
|
+
!tabConfig.actions.some((s) => s.id === opts.action)) {
|
|
77
|
+
logErr(error(`Action "${opts.action}" not found.`) +
|
|
78
|
+
` Available: ${tabConfig.actions.map((s) => s.id).join(", ")}`);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const resolvedConfig = {
|
|
83
|
+
...reconciled.config,
|
|
84
|
+
actions: tabConfig.actions,
|
|
85
|
+
};
|
|
86
|
+
// Convert CLI range format (2-50) to engine format (2:50)
|
|
87
|
+
const range = opts.rows ? opts.rows.replace("-", ":") : undefined;
|
|
88
|
+
// Build filtered env (only ROWBOUND_*, referenced {{env.X}}, NODE_ENV, PATH)
|
|
89
|
+
const env = buildSafeEnv(resolvedConfig);
|
|
90
|
+
// Set up abort controller for graceful shutdown
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const shutdown = () => {
|
|
93
|
+
if (controller.signal.aborted) {
|
|
94
|
+
log(warn("\nForce quitting..."));
|
|
95
|
+
process.exit(130);
|
|
96
|
+
}
|
|
97
|
+
log(warn("\nShutting down gracefully (Ctrl+C again to force quit)..."));
|
|
98
|
+
controller.abort();
|
|
99
|
+
};
|
|
100
|
+
process.on("SIGINT", shutdown);
|
|
101
|
+
process.on("SIGTERM", shutdown);
|
|
102
|
+
// Create run state for tracking
|
|
103
|
+
const state = createRunState({
|
|
104
|
+
sheetId,
|
|
105
|
+
sheetName: opts.tab,
|
|
106
|
+
config: resolvedConfig,
|
|
107
|
+
totalRows: 0, // Will be set after runPipeline returns
|
|
108
|
+
dryRun: opts.dryRun,
|
|
109
|
+
range,
|
|
110
|
+
actionFilter: opts.action,
|
|
111
|
+
});
|
|
112
|
+
const tracker = createRunTracker(state);
|
|
113
|
+
if (opts.dryRun) {
|
|
114
|
+
log(warn("DRY RUN — no writes will be made\n"));
|
|
115
|
+
}
|
|
116
|
+
log(`Running pipeline on sheet ${dim(sheetId)}...`);
|
|
117
|
+
if (opts.action) {
|
|
118
|
+
log(`Filtering to action: ${bold(opts.action)}`);
|
|
119
|
+
}
|
|
120
|
+
if (opts.rows) {
|
|
121
|
+
log(`Row range: ${bold(opts.rows)}`);
|
|
122
|
+
}
|
|
123
|
+
log("");
|
|
124
|
+
// Track total rows for progress display
|
|
125
|
+
let totalRowsToProcess = 0;
|
|
126
|
+
let currentRow = 0;
|
|
127
|
+
const result = await runPipeline({
|
|
128
|
+
adapter,
|
|
129
|
+
ref,
|
|
130
|
+
config: resolvedConfig,
|
|
131
|
+
env,
|
|
132
|
+
range,
|
|
133
|
+
actionFilter: opts.action,
|
|
134
|
+
dryRun: opts.dryRun,
|
|
135
|
+
signal: controller.signal,
|
|
136
|
+
columnMap: tabConfig.columns,
|
|
137
|
+
onTotalRows: (total) => {
|
|
138
|
+
totalRowsToProcess = total;
|
|
139
|
+
},
|
|
140
|
+
onRowStart: (rowIndex, row) => {
|
|
141
|
+
tracker.onRowStart(rowIndex, row);
|
|
142
|
+
currentRow++;
|
|
143
|
+
if (!quietMode) {
|
|
144
|
+
const progress = totalRowsToProcess > 0
|
|
145
|
+
? `Processing row ${currentRow} of ${totalRowsToProcess}...`
|
|
146
|
+
: `Processing row ${rowIndex + 2}...`;
|
|
147
|
+
log(progress);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
onActionComplete: (rowIndex, actionId, value) => {
|
|
151
|
+
tracker.onActionComplete(rowIndex, actionId, value);
|
|
152
|
+
if (!quietMode) {
|
|
153
|
+
if (value !== null) {
|
|
154
|
+
log(` ${success("\u2713")} ${actionId}: ${value}`);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
log(` ${dim("-")} ${actionId}: ${warn("skipped")}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
onError: (rowIndex, actionId, err) => {
|
|
162
|
+
tracker.onError(rowIndex, actionId, err);
|
|
163
|
+
if (!quietMode) {
|
|
164
|
+
log(` ${error("\u2717")} ${actionId}: ${error(err.message)}`);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
onRowComplete: (rowIndex, updates) => {
|
|
168
|
+
tracker.onRowComplete(rowIndex, updates);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
// Finalize run tracking
|
|
172
|
+
state.totalRows = result.totalRows;
|
|
173
|
+
await tracker.finalize(controller.signal.aborted);
|
|
174
|
+
if (controller.signal.aborted) {
|
|
175
|
+
process.exitCode = 130;
|
|
176
|
+
}
|
|
177
|
+
if (jsonMode) {
|
|
178
|
+
console.log(JSON.stringify(result, null, 2));
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
// Print summary
|
|
182
|
+
console.log(`\n${bold("--- Summary ---")}`);
|
|
183
|
+
console.log(`Run ID: ${dim(state.runId)}`);
|
|
184
|
+
console.log(`Rows processed: ${bold(String(result.processedRows))}`);
|
|
185
|
+
console.log(`Cell updates: ${bold(String(result.updates))}`);
|
|
186
|
+
console.log(`Errors: ${result.errors.length > 0 ? error(String(result.errors.length)) : success(String(result.errors.length))}`);
|
|
187
|
+
if (result.skippedRows > 0) {
|
|
188
|
+
console.log(`Rows skipped: ${warn(String(result.skippedRows))}`);
|
|
189
|
+
}
|
|
190
|
+
if (result.errors.length > 0) {
|
|
191
|
+
console.log(`\n${error("Errors:")}`);
|
|
192
|
+
for (const err of result.errors) {
|
|
193
|
+
console.log(` Row ${err.rowIndex + 2}, action "${err.actionId}": ${error(err.error)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Clean up signal handlers
|
|
198
|
+
process.removeListener("SIGINT", shutdown);
|
|
199
|
+
process.removeListener("SIGTERM", shutdown);
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
203
|
+
if (jsonMode) {
|
|
204
|
+
console.log(JSON.stringify({ error: msg }, null, 2));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
console.error(error("Pipeline failed:"), msg);
|
|
208
|
+
}
|
|
209
|
+
process.exitCode = 1;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
package/dist/cli/runs.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { formatRunDetail, formatRunList } from "../core/run-format.js";
|
|
3
|
+
import { getRunsDir, listRuns, readRunState } from "../core/run-state.js";
|
|
4
|
+
import { dim, error, success, warn } from "./format.js";
|
|
5
|
+
/**
|
|
6
|
+
* Colorize status icons in run format output.
|
|
7
|
+
* ✓ -> green, ✗ -> red, ⚠ -> yellow, ⊘ -> dim, ⏳ -> dim
|
|
8
|
+
*/
|
|
9
|
+
function colorizeRunOutput(text) {
|
|
10
|
+
return text
|
|
11
|
+
.replace(/✓/g, success("✓"))
|
|
12
|
+
.replace(/✗/g, error("✗"))
|
|
13
|
+
.replace(/⚠/g, warn("⚠"))
|
|
14
|
+
.replace(/⊘/g, dim("⊘"))
|
|
15
|
+
.replace(/⏳/g, dim("⏳"));
|
|
16
|
+
}
|
|
17
|
+
export function registerRuns(program) {
|
|
18
|
+
const runsCmd = program
|
|
19
|
+
.command("runs")
|
|
20
|
+
.description("List and inspect pipeline runs")
|
|
21
|
+
.option("--sheet <id>", "Filter by sheet ID")
|
|
22
|
+
.option("--limit <n>", "Number of runs to show", "20")
|
|
23
|
+
.option("--json", "Output as JSON instead of table")
|
|
24
|
+
.option("--last", "Show detail view of the most recent run")
|
|
25
|
+
.option("--errors", "Show only errors (use with --last or a run ID)")
|
|
26
|
+
.argument("[runId]", "Show detail view of a specific run")
|
|
27
|
+
.action(async (runId, opts) => {
|
|
28
|
+
// Detail view: specific run by ID
|
|
29
|
+
if (runId) {
|
|
30
|
+
const run = await readRunState(runId);
|
|
31
|
+
if (!run) {
|
|
32
|
+
console.error(`Run "${runId}" not found.`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.log(colorizeRunOutput(formatRunDetail(run, opts.errors ?? false)));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Detail view: most recent run (--last)
|
|
40
|
+
if (opts.last) {
|
|
41
|
+
const runs = await listRuns({ limit: 1 });
|
|
42
|
+
if (runs.length === 0) {
|
|
43
|
+
console.error("No runs found.");
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
console.log(colorizeRunOutput(formatRunDetail(runs[0], opts.errors ?? false)));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// List view (default)
|
|
51
|
+
const limit = parseInt(opts.limit, 10);
|
|
52
|
+
if (Number.isNaN(limit) || limit < 1) {
|
|
53
|
+
console.error("Invalid --limit value. Must be a positive integer.");
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const runs = await listRuns({ sheetId: opts.sheet, limit });
|
|
58
|
+
if (opts.json) {
|
|
59
|
+
console.log(JSON.stringify(runs, null, 2));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
console.log(colorizeRunOutput(formatRunList(runs)));
|
|
63
|
+
});
|
|
64
|
+
// Subcommand: rowbound runs clear
|
|
65
|
+
runsCmd
|
|
66
|
+
.command("clear")
|
|
67
|
+
.description("Delete all run history")
|
|
68
|
+
.option("-f, --force", "Skip confirmation prompt")
|
|
69
|
+
.action(async (opts) => {
|
|
70
|
+
const dir = await getRunsDir();
|
|
71
|
+
let files;
|
|
72
|
+
try {
|
|
73
|
+
files = (await fs.readdir(dir)).filter((f) => f.endsWith(".json"));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
console.log("Deleted 0 runs.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (files.length === 0) {
|
|
80
|
+
console.log("Deleted 0 runs.");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!opts.force) {
|
|
84
|
+
const readline = await import("node:readline/promises");
|
|
85
|
+
const rl = readline.createInterface({
|
|
86
|
+
input: process.stdin,
|
|
87
|
+
output: process.stdout,
|
|
88
|
+
});
|
|
89
|
+
const answer = await rl.question(`Delete ${files.length} run(s)? [y/N] `);
|
|
90
|
+
rl.close();
|
|
91
|
+
if (answer.toLowerCase() !== "y") {
|
|
92
|
+
console.log("Cancelled.");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let deleted = 0;
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
try {
|
|
99
|
+
await fs.unlink(`${dir}/${file}`);
|
|
100
|
+
deleted++;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Ignore deletion errors
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
console.log(`Deleted ${deleted} run${deleted !== 1 ? "s" : ""}.`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { SheetsAdapter } from "../adapters/sheets/sheets-adapter.js";
|
|
2
|
+
import { getTabConfig } from "../core/tab-resolver.js";
|
|
3
|
+
import { bold, dim, error, success, warn } from "./format.js";
|
|
4
|
+
export function registerStatus(program) {
|
|
5
|
+
program
|
|
6
|
+
.command("status")
|
|
7
|
+
.description("Show pipeline status overview")
|
|
8
|
+
.argument("<sheetId>", "Google Sheets spreadsheet ID")
|
|
9
|
+
.option("--tab <name>", "Sheet tab name")
|
|
10
|
+
.option("--json", "Output as JSON")
|
|
11
|
+
.action(async (sheetId, opts) => {
|
|
12
|
+
const adapter = new SheetsAdapter();
|
|
13
|
+
const tabName = opts.tab ?? "Sheet1";
|
|
14
|
+
const ref = { spreadsheetId: sheetId, sheetName: tabName };
|
|
15
|
+
try {
|
|
16
|
+
const config = await adapter.readConfig(ref);
|
|
17
|
+
if (!config) {
|
|
18
|
+
console.error(error("No Rowbound config found. Run 'rowbound init <sheetId>' first."));
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// Resolve actions from tab config (v2) or top-level (v1)
|
|
23
|
+
let actions;
|
|
24
|
+
if (config.tabs) {
|
|
25
|
+
try {
|
|
26
|
+
const { tab } = getTabConfig(config, opts.tab);
|
|
27
|
+
actions = tab.actions;
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
console.error(error(e instanceof Error ? e.message : String(e)));
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
actions = config.actions;
|
|
37
|
+
}
|
|
38
|
+
// Read rows to build enrichment status
|
|
39
|
+
const enrichment = [];
|
|
40
|
+
let rowCount = 0;
|
|
41
|
+
try {
|
|
42
|
+
const rows = await adapter.readRows(ref);
|
|
43
|
+
rowCount = rows.length;
|
|
44
|
+
const targetColumns = [...new Set(actions.map((s) => s.target))];
|
|
45
|
+
if (targetColumns.length > 0 && rows.length > 0) {
|
|
46
|
+
for (const target of targetColumns) {
|
|
47
|
+
const filled = rows.filter((row) => row[target] !== undefined && row[target] !== "").length;
|
|
48
|
+
const pct = rows.length > 0 ? Math.round((filled / rows.length) * 100) : 0;
|
|
49
|
+
enrichment.push({ target, filled, total: rows.length, pct });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (readErr) {
|
|
54
|
+
const readMsg = readErr instanceof Error ? readErr.message : String(readErr);
|
|
55
|
+
console.log(warn(`Could not read sheet data: ${readMsg}`));
|
|
56
|
+
}
|
|
57
|
+
if (opts.json) {
|
|
58
|
+
const data = {
|
|
59
|
+
actions: actions.map((s) => ({
|
|
60
|
+
id: s.id,
|
|
61
|
+
type: s.type,
|
|
62
|
+
target: s.target,
|
|
63
|
+
})),
|
|
64
|
+
settings: config.settings,
|
|
65
|
+
rows: rowCount,
|
|
66
|
+
enrichment,
|
|
67
|
+
};
|
|
68
|
+
console.log(JSON.stringify(data, null, 2));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
console.log(bold("Pipeline Status"));
|
|
72
|
+
console.log("===============\n");
|
|
73
|
+
// Action summary
|
|
74
|
+
console.log(`Actions: ${bold(String(actions.length))}`);
|
|
75
|
+
if (actions.length > 0) {
|
|
76
|
+
console.log();
|
|
77
|
+
for (const action of actions) {
|
|
78
|
+
console.log(` ${bold(action.id)} ${dim(`(${action.type})`)} -> ${action.target}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Settings
|
|
82
|
+
console.log(dim("\nSettings:"));
|
|
83
|
+
console.log(` Concurrency: ${config.settings.concurrency}`);
|
|
84
|
+
console.log(` Rate limit: ${config.settings.rateLimit}/s`);
|
|
85
|
+
console.log(` Retry: ${config.settings.retryAttempts} attempts (${config.settings.retryBackoff})`);
|
|
86
|
+
console.log(`\nData: ${bold(String(rowCount))} rows`);
|
|
87
|
+
if (enrichment.length > 0) {
|
|
88
|
+
console.log("\nEnrichment status:");
|
|
89
|
+
for (const e of enrichment) {
|
|
90
|
+
const colorPct = e.pct >= 80
|
|
91
|
+
? success(`${e.pct}%`)
|
|
92
|
+
: e.pct >= 50
|
|
93
|
+
? warn(`${e.pct}%`)
|
|
94
|
+
: error(`${e.pct}%`);
|
|
95
|
+
console.log(` ${e.target}: ${e.filled}/${e.total} filled (${colorPct})`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (rowCount === 0) {
|
|
99
|
+
console.log(dim("\n(Could not read sheet data for enrichment status)"));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
104
|
+
console.error(error("Failed to get status:"), msg);
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
package/dist/cli/sync.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { SheetsAdapter } from "../adapters/sheets/sheets-adapter.js";
|
|
2
|
+
import { reconcile } from "../core/reconcile.js";
|
|
3
|
+
import { validateConfig } from "../core/validator.js";
|
|
4
|
+
import { dim, error, success, warn } from "./format.js";
|
|
5
|
+
export function registerSync(program) {
|
|
6
|
+
program
|
|
7
|
+
.command("sync")
|
|
8
|
+
.description("Sync pipeline config with the sheet — reconcile columns, migrate action targets, validate, and fix issues")
|
|
9
|
+
.argument("<sheetId>", "Google Sheets spreadsheet ID")
|
|
10
|
+
.option("--tab <name>", "Sheet tab name", "Sheet1")
|
|
11
|
+
.action(async (sheetId, opts) => {
|
|
12
|
+
const adapter = new SheetsAdapter();
|
|
13
|
+
const ref = { spreadsheetId: sheetId, sheetName: opts.tab };
|
|
14
|
+
try {
|
|
15
|
+
const config = await adapter.readConfig(ref);
|
|
16
|
+
if (!config) {
|
|
17
|
+
console.error(error("No Rowbound config found.") +
|
|
18
|
+
" Run 'rowbound init <sheetId>' first.");
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// --- 1. Reconcile columns (and migrate v1→v2 if needed) ---
|
|
23
|
+
const reconciled = await reconcile(adapter, ref, config);
|
|
24
|
+
const tabConfig = reconciled.tabConfig;
|
|
25
|
+
if (reconciled.messages.length > 0) {
|
|
26
|
+
console.log(dim("\u21BB Reconciling columns..."));
|
|
27
|
+
for (const msg of reconciled.messages) {
|
|
28
|
+
console.log(` ${dim(msg)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.log(`${success("\u2713")} Columns in sync`);
|
|
33
|
+
}
|
|
34
|
+
// --- 2. Check for orphaned action targets ---
|
|
35
|
+
const columns = tabConfig.columns;
|
|
36
|
+
const warnings = [];
|
|
37
|
+
for (const action of tabConfig.actions) {
|
|
38
|
+
if (!columns[action.target]) {
|
|
39
|
+
warnings.push(`Action "${action.id}" targets "${action.target}" which is not a known column ID`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// --- 3. Check for unreferenced columns (info, not a problem) ---
|
|
43
|
+
const targetedIds = new Set(tabConfig.actions.map((s) => s.target));
|
|
44
|
+
const _untargeted = Object.entries(columns)
|
|
45
|
+
.filter(([id]) => !targetedIds.has(id))
|
|
46
|
+
.map(([id, name]) => `${name} (${id})`);
|
|
47
|
+
// --- 4. Validate config ---
|
|
48
|
+
const validationConfig = {
|
|
49
|
+
...reconciled.config,
|
|
50
|
+
actions: tabConfig.actions,
|
|
51
|
+
};
|
|
52
|
+
const validation = validateConfig(validationConfig);
|
|
53
|
+
if (validation.errors.length > 0) {
|
|
54
|
+
console.error(`\n${error("\u2717")} Validation errors:`);
|
|
55
|
+
for (const e of validation.errors) {
|
|
56
|
+
console.error(` ${error("-")} ${e}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (validation.warnings.length > 0 || warnings.length > 0) {
|
|
60
|
+
console.log(`\n${warn("\u26A0")} Warnings:`);
|
|
61
|
+
for (const w of [...warnings, ...validation.warnings]) {
|
|
62
|
+
console.log(` ${warn("-")} ${w}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// --- 5. Save if anything changed ---
|
|
66
|
+
if (reconciled.configChanged) {
|
|
67
|
+
await adapter.writeConfig(ref, reconciled.config);
|
|
68
|
+
console.log(`\n${success("\u2713")} Config saved`);
|
|
69
|
+
}
|
|
70
|
+
// --- 6. Summary ---
|
|
71
|
+
const cols = Object.keys(columns).length;
|
|
72
|
+
const actions = tabConfig.actions.length;
|
|
73
|
+
console.log(`\n${cols} columns tracked, ${actions} actions configured`);
|
|
74
|
+
if (validation.errors.length > 0 || warnings.length > 0) {
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
80
|
+
console.error(error("Sync failed:"), msg);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|