@sapporta/server 0.0.1
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/package.json +40 -0
- package/src/actions/action.test.ts +108 -0
- package/src/actions/action.ts +60 -0
- package/src/actions/loader.ts +47 -0
- package/src/api/actions.ts +124 -0
- package/src/api/meta-mutations.ts +922 -0
- package/src/api/meta.ts +222 -0
- package/src/api/reports.ts +98 -0
- package/src/api/server.ts +24 -0
- package/src/api/tables.ts +108 -0
- package/src/api/views.ts +44 -0
- package/src/boot.ts +206 -0
- package/src/cli/ai-commands.ts +220 -0
- package/src/cli/check.ts +169 -0
- package/src/cli/cli-utils.test.ts +313 -0
- package/src/cli/describe.test.ts +151 -0
- package/src/cli/describe.ts +88 -0
- package/src/cli/emit-result.test.ts +160 -0
- package/src/cli/format.ts +150 -0
- package/src/cli/http-client.ts +55 -0
- package/src/cli/index.ts +162 -0
- package/src/cli/init.ts +35 -0
- package/src/cli/project-context.ts +38 -0
- package/src/cli/request.ts +146 -0
- package/src/cli/routes.ts +418 -0
- package/src/cli/rows-insert-master-detail.test.ts +124 -0
- package/src/cli/rows-insert-master-detail.ts +186 -0
- package/src/cli/rows-insert.test.ts +137 -0
- package/src/cli/rows-insert.ts +97 -0
- package/src/cli/serve-single.ts +49 -0
- package/src/create-project.ts +81 -0
- package/src/data/count.ts +62 -0
- package/src/data/crud.test.ts +188 -0
- package/src/data/crud.ts +242 -0
- package/src/data/lookup.test.ts +96 -0
- package/src/data/lookup.ts +104 -0
- package/src/data/query-parser.test.ts +67 -0
- package/src/data/query-parser.ts +106 -0
- package/src/data/sanitize.test.ts +57 -0
- package/src/data/sanitize.ts +25 -0
- package/src/data/save-pipeline.test.ts +115 -0
- package/src/data/save-pipeline.ts +93 -0
- package/src/data/validate.test.ts +110 -0
- package/src/data/validate.ts +98 -0
- package/src/db/errors.ts +20 -0
- package/src/db/logger.ts +63 -0
- package/src/db/sqlite-connection.test.ts +59 -0
- package/src/db/sqlite-connection.ts +79 -0
- package/src/index.ts +111 -0
- package/src/integration/api-actions.test.ts +60 -0
- package/src/integration/api-global.test.ts +21 -0
- package/src/integration/api-meta.test.ts +252 -0
- package/src/integration/api-reports.test.ts +77 -0
- package/src/integration/api-tables.test.ts +238 -0
- package/src/integration/api-views.test.ts +39 -0
- package/src/integration/cli-routes.test.ts +167 -0
- package/src/integration/fixtures/actions/create-account.ts +23 -0
- package/src/integration/fixtures/reports/account-list.ts +25 -0
- package/src/integration/fixtures/schema/accounts.ts +21 -0
- package/src/integration/fixtures/schema/audit-log.ts +19 -0
- package/src/integration/fixtures/schema/journal-entries.ts +20 -0
- package/src/integration/fixtures/views/dashboard.tsx +4 -0
- package/src/integration/fixtures/views/settings.tsx +3 -0
- package/src/integration/setup.ts +72 -0
- package/src/introspect/db-helpers.ts +109 -0
- package/src/introspect/describe-all.test.ts +73 -0
- package/src/introspect/describe-all.ts +80 -0
- package/src/introspect/describe.test.ts +65 -0
- package/src/introspect/describe.ts +184 -0
- package/src/introspect/exec.test.ts +103 -0
- package/src/introspect/exec.ts +57 -0
- package/src/introspect/indexes.test.ts +41 -0
- package/src/introspect/indexes.ts +95 -0
- package/src/introspect/inference.ts +98 -0
- package/src/introspect/list-tables.test.ts +40 -0
- package/src/introspect/list-tables.ts +62 -0
- package/src/introspect/query.test.ts +77 -0
- package/src/introspect/query.ts +47 -0
- package/src/introspect/sample.test.ts +67 -0
- package/src/introspect/sample.ts +50 -0
- package/src/introspect/sql-safety.ts +76 -0
- package/src/introspect/sqlite/db-helpers.test.ts +79 -0
- package/src/introspect/sqlite/db-helpers.ts +56 -0
- package/src/introspect/sqlite/describe-all.ts +21 -0
- package/src/introspect/sqlite/describe.test.ts +160 -0
- package/src/introspect/sqlite/describe.ts +185 -0
- package/src/introspect/sqlite/exec.ts +57 -0
- package/src/introspect/sqlite/indexes.test.ts +60 -0
- package/src/introspect/sqlite/indexes.ts +96 -0
- package/src/introspect/sqlite/list-tables.test.ts +100 -0
- package/src/introspect/sqlite/list-tables.ts +67 -0
- package/src/introspect/sqlite/query.ts +49 -0
- package/src/introspect/sqlite/sample.ts +50 -0
- package/src/introspect/table-rename.test.ts +235 -0
- package/src/introspect/table-rename.ts +115 -0
- package/src/introspect/types.ts +95 -0
- package/src/reports/check.test.ts +499 -0
- package/src/reports/check.ts +208 -0
- package/src/reports/engine.test.ts +1465 -0
- package/src/reports/engine.ts +678 -0
- package/src/reports/loader.ts +55 -0
- package/src/reports/report.ts +308 -0
- package/src/reports/sql-bind.ts +161 -0
- package/src/reports/sqlite-bind.test.ts +98 -0
- package/src/reports/sqlite-bind.ts +58 -0
- package/src/reports/sqlite-sql-client.ts +42 -0
- package/src/runtime.ts +3 -0
- package/src/schema/check.ts +90 -0
- package/src/schema/ddl.test.ts +210 -0
- package/src/schema/ddl.ts +180 -0
- package/src/schema/dynamic-builder.ts +297 -0
- package/src/schema/extract.test.ts +261 -0
- package/src/schema/extract.ts +285 -0
- package/src/schema/loader.test.ts +31 -0
- package/src/schema/loader.ts +60 -0
- package/src/schema/metadata-io.test.ts +261 -0
- package/src/schema/metadata-io.ts +161 -0
- package/src/schema/metadata-tables.test.ts +737 -0
- package/src/schema/metadata-tables.ts +341 -0
- package/src/schema/migrate.ts +195 -0
- package/src/schema/normalize-datatype.test.ts +58 -0
- package/src/schema/normalize-datatype.ts +99 -0
- package/src/schema/registry.test.ts +174 -0
- package/src/schema/registry.ts +139 -0
- package/src/schema/reserved.ts +227 -0
- package/src/schema/table.ts +135 -0
- package/src/test-fixtures/schema/accounts.ts +24 -0
- package/src/test-fixtures/schema/not-a-table.ts +6 -0
- package/src/testing/test-utils.ts +44 -0
- package/src/views/loader.test.ts +70 -0
- package/src/views/loader.ts +38 -0
- package/src/views/view.test.ts +121 -0
- package/src/views/view.ts +16 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { resolveOutputFormat, emitResult } from "./format.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// resolveOutputFormat tests
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Resolution priority: --output flag > SAPPORTA_OUTPUT_FORMAT env > TTY detection.
|
|
8
|
+
// This order matters because agents running in pipelines need predictable
|
|
9
|
+
// format selection without needing to pass --output on every call.
|
|
10
|
+
|
|
11
|
+
describe("resolveOutputFormat", () => {
|
|
12
|
+
const originalEnv = process.env.SAPPORTA_OUTPUT_FORMAT;
|
|
13
|
+
const originalIsTTY = process.stdout.isTTY;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
// Restore environment to prevent test pollution
|
|
17
|
+
if (originalEnv === undefined) {
|
|
18
|
+
delete process.env.SAPPORTA_OUTPUT_FORMAT;
|
|
19
|
+
} else {
|
|
20
|
+
process.env.SAPPORTA_OUTPUT_FORMAT = originalEnv;
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, writable: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns json when --output json is passed", () => {
|
|
26
|
+
expect(resolveOutputFormat({ output: "json" })).toBe("json");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns table when --output table is passed", () => {
|
|
30
|
+
expect(resolveOutputFormat({ output: "table" })).toBe("table");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("uses SAPPORTA_OUTPUT_FORMAT env when no flag", () => {
|
|
34
|
+
process.env.SAPPORTA_OUTPUT_FORMAT = "json";
|
|
35
|
+
expect(resolveOutputFormat({})).toBe("json");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("--output flag takes precedence over env var", () => {
|
|
39
|
+
process.env.SAPPORTA_OUTPUT_FORMAT = "json";
|
|
40
|
+
expect(resolveOutputFormat({ output: "table" })).toBe("table");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("defaults to json when stdout is not a TTY (agent/pipe mode)", () => {
|
|
44
|
+
delete process.env.SAPPORTA_OUTPUT_FORMAT;
|
|
45
|
+
Object.defineProperty(process.stdout, "isTTY", { value: undefined, writable: true });
|
|
46
|
+
expect(resolveOutputFormat({})).toBe("json");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("defaults to table when stdout is a TTY (interactive mode)", () => {
|
|
50
|
+
delete process.env.SAPPORTA_OUTPUT_FORMAT;
|
|
51
|
+
Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true });
|
|
52
|
+
expect(resolveOutputFormat({})).toBe("table");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// emitResult tests
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// emitResult is the SOLE output point — commands never call console.log
|
|
60
|
+
// directly. These tests verify both JSON and table mode rendering.
|
|
61
|
+
|
|
62
|
+
describe("emitResult", () => {
|
|
63
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
64
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
68
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
logSpy.mockRestore();
|
|
73
|
+
errorSpy.mockRestore();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// -- JSON mode --
|
|
77
|
+
|
|
78
|
+
it("JSON mode: emits success result as single JSON line", () => {
|
|
79
|
+
emitResult({ ok: true, data: [{ id: 1 }] }, "json");
|
|
80
|
+
expect(logSpy).toHaveBeenCalledOnce();
|
|
81
|
+
const parsed = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
82
|
+
expect(parsed.ok).toBe(true);
|
|
83
|
+
expect(parsed.data).toEqual([{ id: 1 }]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("JSON mode: emits error result as single JSON line", () => {
|
|
87
|
+
emitResult({ ok: false, error: "not found", code: "TABLE_NOT_FOUND" }, "json");
|
|
88
|
+
expect(logSpy).toHaveBeenCalledOnce();
|
|
89
|
+
const parsed = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
90
|
+
expect(parsed.ok).toBe(false);
|
|
91
|
+
expect(parsed.error).toBe("not found");
|
|
92
|
+
expect(parsed.code).toBe("TABLE_NOT_FOUND");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// -- Table mode --
|
|
96
|
+
|
|
97
|
+
it("table mode: prints error to stderr", () => {
|
|
98
|
+
emitResult({ ok: false, error: "bad input", code: "INVALID_JSON" }, "table");
|
|
99
|
+
expect(errorSpy).toHaveBeenCalledWith("Error: bad input");
|
|
100
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("table mode: prints message from meta", () => {
|
|
104
|
+
emitResult({ ok: true, data: [], meta: { message: "Done!" } }, "table");
|
|
105
|
+
expect(logSpy).toHaveBeenCalledWith("Done!");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("table mode: renders data rows as a formatted table", () => {
|
|
109
|
+
emitResult(
|
|
110
|
+
{ ok: true, data: [{ id: 1, name: "Cash" }] },
|
|
111
|
+
"table",
|
|
112
|
+
);
|
|
113
|
+
// formatTable produces header + separator + data rows
|
|
114
|
+
const output = logSpy.mock.calls[0][0] as string;
|
|
115
|
+
expect(output).toContain("id");
|
|
116
|
+
expect(output).toContain("name");
|
|
117
|
+
expect(output).toContain("Cash");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("table mode: skips formatTable when tableOutputHandled is true", () => {
|
|
121
|
+
// Commands like report-execute set tableOutputHandled to indicate they've
|
|
122
|
+
// already formatted the output into the message/additionalOutput fields.
|
|
123
|
+
emitResult(
|
|
124
|
+
{
|
|
125
|
+
ok: true,
|
|
126
|
+
data: [{ id: 1 }],
|
|
127
|
+
meta: { message: "Custom output", tableOutputHandled: true },
|
|
128
|
+
},
|
|
129
|
+
"table",
|
|
130
|
+
);
|
|
131
|
+
// Only the message should be logged, not the data table
|
|
132
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(logSpy).toHaveBeenCalledWith("Custom output");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("table mode: emits additionalOutput after the main table", () => {
|
|
137
|
+
emitResult(
|
|
138
|
+
{
|
|
139
|
+
ok: true,
|
|
140
|
+
data: [],
|
|
141
|
+
meta: { message: "Header", additionalOutput: "Extra section" },
|
|
142
|
+
},
|
|
143
|
+
"table",
|
|
144
|
+
);
|
|
145
|
+
expect(logSpy).toHaveBeenCalledWith("Header");
|
|
146
|
+
expect(logSpy).toHaveBeenCalledWith("Extra section");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("table mode: emits errorText to stderr", () => {
|
|
150
|
+
emitResult(
|
|
151
|
+
{
|
|
152
|
+
ok: true,
|
|
153
|
+
data: [],
|
|
154
|
+
meta: { errorText: "Warning: something odd" },
|
|
155
|
+
},
|
|
156
|
+
"table",
|
|
157
|
+
);
|
|
158
|
+
expect(errorSpy).toHaveBeenCalledWith("Warning: something odd");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { OperationResult } from "../introspect/types.js";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// CLI output formatting and flag parsing.
|
|
5
|
+
//
|
|
6
|
+
// Everything in this file is CLI-specific presentation logic — it converts
|
|
7
|
+
// structured OperationResults into human-readable terminal output or JSON.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export type OutputFormat = "table" | "json";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the output format from flags, environment, and TTY detection.
|
|
14
|
+
* Priority: --output flag > SAPPORTA_OUTPUT_FORMAT env > TTY detection.
|
|
15
|
+
*
|
|
16
|
+
* Non-TTY stdout (e.g. piped to another program or used by an AI agent)
|
|
17
|
+
* defaults to JSON so agents get structured data without needing to pass flags.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveOutputFormat(
|
|
20
|
+
flags: Record<string, string>,
|
|
21
|
+
): OutputFormat {
|
|
22
|
+
const explicit =
|
|
23
|
+
flags.output ?? process.env.SAPPORTA_OUTPUT_FORMAT;
|
|
24
|
+
if (explicit === "json") return "json";
|
|
25
|
+
if (explicit === "table") return "table";
|
|
26
|
+
// Auto-detect: non-TTY defaults to JSON for agent consumption
|
|
27
|
+
if (!process.stdout.isTTY) return "json";
|
|
28
|
+
return "table";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Format and emit an OperationResult to stdout.
|
|
33
|
+
*
|
|
34
|
+
* In table mode: reproduces the human-readable output the CLI has always produced.
|
|
35
|
+
* In JSON mode: emits the full result envelope as a single JSON line.
|
|
36
|
+
*
|
|
37
|
+
* This is the ONLY place in the codebase that should call console.log for
|
|
38
|
+
* command output. Commands themselves must never print directly.
|
|
39
|
+
*/
|
|
40
|
+
export function emitResult(result: OperationResult, format: OutputFormat): void {
|
|
41
|
+
if (format === "json") {
|
|
42
|
+
console.log(JSON.stringify(result));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Table format: reproduce legacy behavior
|
|
47
|
+
if (!result.ok) {
|
|
48
|
+
console.error(`Error: ${result.error}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (result.meta?.message) {
|
|
53
|
+
console.log(result.meta.message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Commands that produce complex multi-section output (e.g. report-execute,
|
|
57
|
+
// report-describe) set meta.tableOutputHandled = true to signal that the
|
|
58
|
+
// message and/or additionalOutput already contain the full rendered output.
|
|
59
|
+
// In that case, skip the default formatTable call to avoid duplicate output.
|
|
60
|
+
if (!result.meta?.tableOutputHandled && result.data.length > 0) {
|
|
61
|
+
console.log(formatTable(result.data));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (result.meta?.additionalOutput) {
|
|
65
|
+
console.log(result.meta.additionalOutput);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Emit error text to stderr (used by report-execute for warnings)
|
|
69
|
+
if (result.meta?.errorText) {
|
|
70
|
+
console.error(result.meta.errorText);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse --flag value pairs from argv.
|
|
76
|
+
* Returns a map of flag → string value.
|
|
77
|
+
* Positional args (no -- prefix) are collected under "_".
|
|
78
|
+
*
|
|
79
|
+
* All values are coerced to strings for a uniform interface —
|
|
80
|
+
* callers can safely pass values as query params or compare against
|
|
81
|
+
* string literals without worrying about type inference.
|
|
82
|
+
*/
|
|
83
|
+
export function parseFlags(argv: string[]): Record<string, any> {
|
|
84
|
+
const result: Record<string, any> = { _: [] };
|
|
85
|
+
for (let i = 0; i < argv.length; i++) {
|
|
86
|
+
const arg = argv[i];
|
|
87
|
+
if (arg === "--") break;
|
|
88
|
+
if (arg.startsWith("--")) {
|
|
89
|
+
const eqIndex = arg.indexOf("=", 2);
|
|
90
|
+
if (eqIndex !== -1) {
|
|
91
|
+
// --key=value form
|
|
92
|
+
result[arg.slice(2, eqIndex)] = arg.slice(eqIndex + 1);
|
|
93
|
+
} else {
|
|
94
|
+
const key = arg.slice(2);
|
|
95
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
|
|
96
|
+
result[key] = String(argv[i + 1]);
|
|
97
|
+
i++;
|
|
98
|
+
} else {
|
|
99
|
+
result[key] = "true";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
result._.push(arg);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Format rows as a readable table for terminal output.
|
|
111
|
+
*/
|
|
112
|
+
export function formatTable(rows: Record<string, unknown>[]): string {
|
|
113
|
+
if (rows.length === 0) return "(empty)";
|
|
114
|
+
|
|
115
|
+
const columns = Object.keys(rows[0]);
|
|
116
|
+
const widths = columns.map((col) => {
|
|
117
|
+
const values = rows.map((row) => String(row[col] ?? "NULL"));
|
|
118
|
+
return Math.max(col.length, ...values.map((v) => v.length));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const header = columns.map((col, i) => col.padEnd(widths[i])).join(" ");
|
|
122
|
+
const separator = widths.map((w) => "-".repeat(w)).join(" ");
|
|
123
|
+
const body = rows.map((row) =>
|
|
124
|
+
columns.map((col, i) => String(row[col] ?? "NULL").padEnd(widths[i])).join(" "),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return [header, separator, ...body].join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Truncate long string values in result rows to prevent context window overflow.
|
|
132
|
+
* Only affects string values longer than maxLen; other types are left as-is.
|
|
133
|
+
* Returns new row objects (does not mutate the originals).
|
|
134
|
+
*/
|
|
135
|
+
export function truncateValues(
|
|
136
|
+
rows: Record<string, unknown>[],
|
|
137
|
+
maxLen: number = 200,
|
|
138
|
+
): Record<string, unknown>[] {
|
|
139
|
+
return rows.map((row) => {
|
|
140
|
+
const truncated: Record<string, unknown> = {};
|
|
141
|
+
for (const [key, value] of Object.entries(row)) {
|
|
142
|
+
if (typeof value === "string" && value.length > maxLen) {
|
|
143
|
+
truncated[key] = value.slice(0, maxLen) + "...";
|
|
144
|
+
} else {
|
|
145
|
+
truncated[key] = value;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return truncated;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP client for the CLI → API bridge.
|
|
3
|
+
*
|
|
4
|
+
* All CLI commands that touch data go through this function.
|
|
5
|
+
* The server runs at SAPPORTA_API_URL (default http://localhost:3000).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface HttpResult {
|
|
9
|
+
status: number;
|
|
10
|
+
data: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function httpRequest(
|
|
14
|
+
baseUrl: string,
|
|
15
|
+
method: string,
|
|
16
|
+
path: string,
|
|
17
|
+
opts?: {
|
|
18
|
+
body?: unknown;
|
|
19
|
+
queryParams?: Record<string, string>;
|
|
20
|
+
token?: string;
|
|
21
|
+
},
|
|
22
|
+
): Promise<HttpResult> {
|
|
23
|
+
// Strip leading "/" so new URL() treats the path as relative to baseUrl's path.
|
|
24
|
+
// With a leading slash, new URL("/meta/tables", "http://host/p/playground/")
|
|
25
|
+
// discards the base path → "http://host/meta/tables" (broken).
|
|
26
|
+
const relativePath = path.startsWith("/") ? path.slice(1) : path;
|
|
27
|
+
const url = new URL(relativePath, baseUrl.endsWith("/") ? baseUrl : baseUrl + "/");
|
|
28
|
+
if (opts?.queryParams) {
|
|
29
|
+
for (const [k, v] of Object.entries(opts.queryParams)) {
|
|
30
|
+
if (v !== undefined && v !== "") url.searchParams.set(k, v);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let res: Response;
|
|
35
|
+
try {
|
|
36
|
+
res = await fetch(url, {
|
|
37
|
+
method,
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
...(opts?.token ? { Authorization: `Bearer ${opts.token}` } : {}),
|
|
41
|
+
},
|
|
42
|
+
...(opts?.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
|
43
|
+
});
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
if (err.code === "ECONNREFUSED" || err.cause?.code === "ECONNREFUSED") {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Cannot connect to Sapporta server at ${baseUrl}. Is the server running?`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
return { status: res.status, data };
|
|
55
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { check } from "./check.js";
|
|
3
|
+
import { init } from "./init.js";
|
|
4
|
+
import { serveSingle } from "./serve-single.js";
|
|
5
|
+
import { describeAll, describeOne } from "./describe.js";
|
|
6
|
+
import { ROUTES, registerRoutes } from "./routes.js";
|
|
7
|
+
import { httpRequest } from "./http-client.js";
|
|
8
|
+
import { OperationError, ErrorCode } from "../introspect/types.js";
|
|
9
|
+
import { buildRequest, renderResult } from "./request.js";
|
|
10
|
+
import { parseFlags, emitResult, resolveOutputFormat, type OutputFormat } from "./format.js";
|
|
11
|
+
|
|
12
|
+
// Re-export for programmatic access
|
|
13
|
+
export { ROUTES } from "./routes.js";
|
|
14
|
+
export type { CliRoute } from "./routes.js";
|
|
15
|
+
export { buildRequest, renderResult } from "./request.js";
|
|
16
|
+
export type { RequestSpec } from "./request.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Emit an error and exit. In table mode, prints to stderr like before.
|
|
20
|
+
* In JSON mode, emits a structured error envelope to stdout.
|
|
21
|
+
*/
|
|
22
|
+
function handleError(err: any, format: OutputFormat): never {
|
|
23
|
+
const code = err instanceof OperationError ? err.code : ErrorCode.INTERNAL;
|
|
24
|
+
const result = { ok: false as const, error: err.message, code };
|
|
25
|
+
emitResult(result, format);
|
|
26
|
+
process.exit(err instanceof OperationError ? 1 : 2);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Base URL resolution
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the API base URL from flags or environment.
|
|
35
|
+
*/
|
|
36
|
+
function resolveBaseUrl(flags: Record<string, string>): string {
|
|
37
|
+
const apiUrl = flags["api-url"] ?? process.env.SAPPORTA_API_URL ?? "http://localhost:3000";
|
|
38
|
+
return apiUrl.replace(/\/+$/, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// API command runner (thin orchestration)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
async function runApiCommand(
|
|
46
|
+
route: Parameters<typeof buildRequest>[0],
|
|
47
|
+
params: Record<string, string>,
|
|
48
|
+
allFlags: Record<string, any>,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const format = resolveOutputFormat(allFlags);
|
|
51
|
+
|
|
52
|
+
const baseUrl = resolveBaseUrl(allFlags);
|
|
53
|
+
const token = allFlags.token ?? process.env.SAPPORTA_API_TOKEN;
|
|
54
|
+
|
|
55
|
+
const req = buildRequest(route, params, allFlags);
|
|
56
|
+
|
|
57
|
+
const result = await httpRequest(baseUrl, req.method, req.urlPath, {
|
|
58
|
+
body: req.body,
|
|
59
|
+
queryParams: req.queryParams,
|
|
60
|
+
token,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const exitCode = renderResult(route, params, result, format);
|
|
64
|
+
if (exitCode !== 0) process.exit(exitCode);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Main
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
async function main() {
|
|
72
|
+
const rawArgs = process.argv.slice(2);
|
|
73
|
+
const firstArg = rawArgs[0];
|
|
74
|
+
|
|
75
|
+
// ── Local commands (no server needed) ────────────────────────────────
|
|
76
|
+
// Intercepted before Commander runs — these handle their own flag parsing.
|
|
77
|
+
|
|
78
|
+
if (firstArg === "check") {
|
|
79
|
+
const format: OutputFormat = "table";
|
|
80
|
+
try {
|
|
81
|
+
const result = await check(rawArgs.slice(1));
|
|
82
|
+
emitResult(result, format);
|
|
83
|
+
if (result.ok && result.meta?.hasIssues) process.exit(1);
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
handleError(err, format);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (firstArg === "init") {
|
|
91
|
+
const format: OutputFormat = "table";
|
|
92
|
+
try {
|
|
93
|
+
const result = await init(rawArgs.slice(1));
|
|
94
|
+
emitResult(result, format);
|
|
95
|
+
} catch (err: any) {
|
|
96
|
+
handleError(err, format);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (firstArg === "serve") {
|
|
102
|
+
try {
|
|
103
|
+
await serveSingle(rawArgs.slice(1));
|
|
104
|
+
} catch (err: any) {
|
|
105
|
+
handleError(err, "table");
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (firstArg === "describe") {
|
|
111
|
+
const rest = rawArgs.slice(1);
|
|
112
|
+
const flags = parseFlags(rest);
|
|
113
|
+
const format = resolveOutputFormat(flags);
|
|
114
|
+
if (!rawArgs[1] || rawArgs[1].startsWith("--")) {
|
|
115
|
+
const result = describeAll(ROUTES);
|
|
116
|
+
emitResult(result, format);
|
|
117
|
+
} else {
|
|
118
|
+
const commandName = rest.filter((s) => !s.startsWith("--")).join(" ");
|
|
119
|
+
const result = describeOne(commandName, ROUTES);
|
|
120
|
+
emitResult(result, format);
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Commander program for API commands ──────────────────────────────
|
|
126
|
+
|
|
127
|
+
const program = new Command("sapporta")
|
|
128
|
+
.version("0.1.0")
|
|
129
|
+
// Declare global options so Commander doesn't confuse their values
|
|
130
|
+
// with subcommand names
|
|
131
|
+
.option("--output <format>", "Output format: table (default) or json")
|
|
132
|
+
.option("--json <data>", "Pass input as JSON object")
|
|
133
|
+
.option("--api-url <url>", "Server URL")
|
|
134
|
+
.option("--token <token>", "Auth token");
|
|
135
|
+
|
|
136
|
+
registerRoutes(program, ROUTES, async (route, params, extraPositionals) => {
|
|
137
|
+
const allFlags = parseFlags(process.argv.slice(2));
|
|
138
|
+
allFlags._ = extraPositionals;
|
|
139
|
+
|
|
140
|
+
const format = resolveOutputFormat(allFlags);
|
|
141
|
+
try {
|
|
142
|
+
await runApiCommand(route, params, allFlags);
|
|
143
|
+
} catch (err: any) {
|
|
144
|
+
handleError(err, format);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
program.addHelpText("after", `
|
|
149
|
+
Local commands (no server required):
|
|
150
|
+
check Validate project definitions
|
|
151
|
+
init [name] [--dir <path>] Initialize project dependencies
|
|
152
|
+
serve [--port N] Start the server
|
|
153
|
+
describe [command] List commands or describe one`);
|
|
154
|
+
|
|
155
|
+
if (!firstArg) {
|
|
156
|
+
program.help();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await program.parseAsync(process.argv);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
main();
|
package/src/cli/init.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { resolve, join } from "node:path";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import type { OperationResult } from "../introspect/types.js";
|
|
4
|
+
import { createProject } from "../create-project.js";
|
|
5
|
+
import { parseFlags } from "./format.js";
|
|
6
|
+
|
|
7
|
+
export async function init(args: string[]): Promise<OperationResult> {
|
|
8
|
+
const flags = parseFlags(args);
|
|
9
|
+
const projectDir = resolve(flags.dir ?? ".");
|
|
10
|
+
const projectName = flags._?.[0];
|
|
11
|
+
|
|
12
|
+
const codeDir = join(projectDir, "code");
|
|
13
|
+
const dataDir = join(projectDir, "data");
|
|
14
|
+
|
|
15
|
+
mkdirSync(codeDir, { recursive: true });
|
|
16
|
+
mkdirSync(dataDir, { recursive: true });
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const result = createProject({ dir: codeDir, name: projectName });
|
|
20
|
+
return {
|
|
21
|
+
ok: true,
|
|
22
|
+
data: [result.packageJson],
|
|
23
|
+
meta: { message: `Initialized project in ${projectDir}. Dependencies installed.` },
|
|
24
|
+
};
|
|
25
|
+
} catch (err: any) {
|
|
26
|
+
if (err.message.startsWith("package.json already exists")) {
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
data: [],
|
|
30
|
+
meta: { message: `${err.message}. Skipping.` },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { OperationError, ErrorCode } from "../introspect/types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project context resolved from environment/flags.
|
|
5
|
+
* Directory-based resolution — no Postgres registry dependency.
|
|
6
|
+
*/
|
|
7
|
+
export interface ProjectContext {
|
|
8
|
+
databasePath: string;
|
|
9
|
+
dir: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve project context from CLI flags and environment variables.
|
|
14
|
+
*
|
|
15
|
+
* Resolution: SAPPORTA_PROJECT_DIR (or --sapporta-project-dir) is the root.
|
|
16
|
+
* From it we derive code/src (schema directory) and data/sqlite.db (database).
|
|
17
|
+
*
|
|
18
|
+
* Each component is individually overridable via flags/env for flexibility.
|
|
19
|
+
*/
|
|
20
|
+
export async function resolveProjectContext(
|
|
21
|
+
flags: Record<string, string>,
|
|
22
|
+
): Promise<ProjectContext> {
|
|
23
|
+
const projectDir = flags["sapporta-project-dir"] ?? process.env.SAPPORTA_PROJECT_DIR;
|
|
24
|
+
|
|
25
|
+
if (!projectDir) {
|
|
26
|
+
throw new OperationError(
|
|
27
|
+
"No project specified. Set SAPPORTA_PROJECT_DIR or use --sapporta-project-dir",
|
|
28
|
+
ErrorCode.PROJECT_NOT_FOUND,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const codeDir = flags["code-dir"] ?? process.env.SAPPORTA_CODE_DIR ?? `${projectDir}/code`;
|
|
33
|
+
const srcDir = `${codeDir}/src`;
|
|
34
|
+
const dataDir = flags["data-dir"] ?? process.env.SAPPORTA_DATA_DIR ?? `${projectDir}/data`;
|
|
35
|
+
const databasePath = `${dataDir}/sqlite.db`;
|
|
36
|
+
|
|
37
|
+
return { databasePath, dir: srcDir };
|
|
38
|
+
}
|