@fragno-dev/cli 0.2.0 → 0.2.3

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 CHANGED
@@ -1,49 +1,48 @@
1
1
  {
2
2
  "name": "@fragno-dev/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
+ "homepage": "https://fragno.dev",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/rejot-dev/fragno.git",
9
+ "directory": "apps/fragno-cli"
10
+ },
11
+ "bin": {
12
+ "fragno-cli": "./bin/run.js"
13
+ },
14
+ "type": "module",
15
+ "main": "./dist/cli.js",
16
+ "module": "./dist/cli.js",
17
+ "types": "./dist/cli.d.ts",
4
18
  "exports": {
5
19
  ".": {
6
- "development": "./src/cli.ts",
7
20
  "types": "./dist/cli.d.ts",
8
21
  "default": "./dist/cli.js"
9
22
  }
10
23
  },
11
- "type": "module",
12
- "bin": {
13
- "fragno-cli": "./bin/run.js"
14
- },
15
- "engines": {
16
- "node": ">=22"
24
+ "dependencies": {
25
+ "@clack/prompts": "^0.11.0",
26
+ "c12": "^3.3.3",
27
+ "gunshi": "^0.26.3",
28
+ "jsonc-parser": "^3.3.1",
29
+ "@fragno-dev/core": "0.2.2",
30
+ "@fragno-dev/db": "0.4.1",
31
+ "@fragno-dev/node": "0.0.9"
17
32
  },
18
33
  "devDependencies": {
19
34
  "@types/node": "^22.19.7",
20
- "@vitest/coverage-istanbul": "^3.2.4",
35
+ "@vitest/coverage-istanbul": "^4.1.0",
21
36
  "@fragno-private/typescript-config": "0.0.1",
22
37
  "@fragno-private/vitest-config": "0.0.0"
23
38
  },
24
- "dependencies": {
25
- "@clack/prompts": "^0.11.0",
26
- "c12": "^3.3.3",
27
- "gunshi": "^0.26.3",
28
- "marked": "^15.0.12",
29
- "marked-terminal": "^7.3.0",
30
- "@fragno-dev/core": "0.2.0",
31
- "@fragno-dev/corpus": "0.0.7",
32
- "@fragno-dev/db": "0.3.0"
33
- },
34
- "main": "./dist/cli.js",
35
- "module": "./dist/cli.js",
36
- "types": "./dist/cli.d.ts",
37
- "repository": {
38
- "type": "git",
39
- "url": "https://github.com/rejot-dev/fragno.git",
40
- "directory": "apps/fragno-cli"
39
+ "engines": {
40
+ "node": ">=22"
41
41
  },
42
- "homepage": "https://fragno.dev",
43
- "license": "MIT",
44
42
  "scripts": {
45
43
  "build": "tsdown",
46
44
  "build:watch": "tsdown --watch",
47
- "types:check": "tsc --noEmit"
45
+ "types:check": "tsc --noEmit",
46
+ "test": "vitest run"
48
47
  }
49
48
  }
package/src/cli.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
3
7
  import { cli, define } from "gunshi";
8
+
4
9
  import { generateCommand } from "./commands/db/generate.js";
5
- import { migrateCommand } from "./commands/db/migrate.js";
6
10
  import { infoCommand } from "./commands/db/info.js";
11
+ import { migrateCommand } from "./commands/db/migrate.js";
7
12
  import { searchCommand } from "./commands/search.js";
8
- import { corpusCommand } from "./commands/corpus.js";
9
- import { readFileSync } from "node:fs";
10
- import { fileURLToPath } from "node:url";
11
- import { dirname, join } from "node:path";
12
13
 
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
15
  const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
@@ -43,10 +44,11 @@ export async function run() {
43
44
  name: "fragno-cli search",
44
45
  version,
45
46
  });
46
- } else if (args[0] === "corpus") {
47
- // Run corpus command directly
48
- await cli(args.slice(1), corpusCommand, {
49
- name: "fragno-cli corpus",
47
+ } else if (args[0] === "serve") {
48
+ // Run serve command directly
49
+ const { serveCommand } = await import("./commands/serve.js");
50
+ await cli(args.slice(1), serveCommand, {
51
+ name: "fragno-cli serve",
50
52
  version,
51
53
  });
52
54
  } else if (args[0] === "db") {
@@ -102,14 +104,14 @@ export async function run() {
102
104
  console.log(" fragno-cli <COMMAND>");
103
105
  console.log("");
104
106
  console.log("COMMANDS:");
107
+ console.log(" serve Start a local HTTP server to serve fragments");
105
108
  console.log(" db Database management commands");
106
109
  console.log(" search Search the Fragno documentation");
107
- console.log(" corpus View code examples and documentation for Fragno");
108
110
  console.log("");
109
111
  console.log("For more info, run any command with the `--help` flag:");
112
+ console.log(" fragno-cli serve --help");
110
113
  console.log(" fragno-cli db --help");
111
114
  console.log(" fragno-cli search --help");
112
- console.log(" fragno-cli corpus --help");
113
115
  console.log("");
114
116
  console.log("OPTIONS:");
115
117
  console.log(" -h, --help Display this help message");
@@ -133,4 +135,4 @@ if (import.meta.main) {
133
135
  await run();
134
136
  }
135
137
 
136
- export { generateCommand, migrateCommand, infoCommand, searchCommand, corpusCommand };
138
+ export { generateCommand, migrateCommand, infoCommand, searchCommand };
@@ -1,7 +1,9 @@
1
1
  import { writeFile, mkdir } from "node:fs/promises";
2
2
  import { resolve, dirname } from "node:path";
3
- import { define } from "gunshi";
3
+
4
4
  import { generateSchemaArtifacts } from "@fragno-dev/db/generation-engine";
5
+ import { define } from "gunshi";
6
+
5
7
  import { importFragmentFiles } from "../../utils/find-fragno-databases";
6
8
 
7
9
  // Define the db generate command with type safety
@@ -1,5 +1,7 @@
1
1
  import { resolve } from "node:path";
2
+
2
3
  import { define } from "gunshi";
4
+
3
5
  import { importFragmentFiles } from "../../utils/find-fragno-databases";
4
6
 
5
7
  export const infoCommand = define({
@@ -1,7 +1,9 @@
1
1
  import { resolve } from "node:path";
2
+
3
+ import { executeMigrations, type ExecuteMigrationResult } from "@fragno-dev/db/generation-engine";
2
4
  import { define } from "gunshi";
5
+
3
6
  import { importFragmentFiles } from "../../utils/find-fragno-databases";
4
- import { executeMigrations, type ExecuteMigrationResult } from "@fragno-dev/db/generation-engine";
5
7
 
6
8
  export const migrateCommand = define({
7
9
  name: "migrate",
@@ -1,4 +1,5 @@
1
1
  import { define } from "gunshi";
2
+
2
3
  import {
3
4
  mergeResultsByUrl,
4
5
  formatAsMarkdown,
@@ -0,0 +1,151 @@
1
+ import { createServer, type Server } from "node:http";
2
+ import { resolve, relative } from "node:path";
3
+
4
+ import { define } from "gunshi";
5
+
6
+ import type { FragnoInstantiatedFragment } from "@fragno-dev/core";
7
+ import { toNodeHandler } from "@fragno-dev/node";
8
+
9
+ import { findFragnoFragments } from "../utils/find-fragno-databases";
10
+ import { loadConfig } from "../utils/load-config";
11
+
12
+ export const serveCommand = define({
13
+ name: "serve",
14
+ description: "Start a local HTTP server to serve one or more Fragno fragments",
15
+ args: {
16
+ port: {
17
+ type: "number",
18
+ short: "p",
19
+ description: "Port to listen on",
20
+ default: 8080,
21
+ },
22
+ host: {
23
+ type: "string",
24
+ short: "H",
25
+ description: "Host to bind to",
26
+ default: "localhost",
27
+ },
28
+ },
29
+ run: async (ctx) => {
30
+ const targets = ctx.positionals;
31
+ const port = ctx.values.port ?? 8080;
32
+ const host = ctx.values.host ?? "localhost";
33
+ const cwd = process.cwd();
34
+
35
+ if (targets.length === 0) {
36
+ throw new Error(
37
+ "No fragment files specified.\n\n" +
38
+ "Usage: fragno-cli serve <fragment-file> [fragment-file...]\n\n" +
39
+ "Example: fragno-cli serve ./src/my-fragment.ts",
40
+ );
41
+ }
42
+
43
+ const targetPaths = targets.map((target) => resolve(cwd, target));
44
+
45
+ // Import all fragment files and find instantiated fragments
46
+ const allFragments: FragnoInstantiatedFragment<
47
+ [],
48
+ unknown,
49
+ Record<string, unknown>,
50
+ Record<string, unknown>,
51
+ Record<string, unknown>,
52
+ unknown,
53
+ Record<string, unknown>
54
+ >[] = [];
55
+
56
+ for (const targetPath of targetPaths) {
57
+ const relativePath = relative(cwd, targetPath);
58
+ const config = await loadConfig(targetPath);
59
+ const fragments = findFragnoFragments(config);
60
+
61
+ if (fragments.length === 0) {
62
+ console.warn(
63
+ `Warning: No instantiated fragments found in ${relativePath}.\n` +
64
+ `Make sure you export an instantiated fragment (e.g., the return value of createMyFragment()).\n`,
65
+ );
66
+ continue;
67
+ }
68
+
69
+ allFragments.push(...fragments);
70
+ console.log(
71
+ ` Found ${fragments.length} fragment(s) in ${relativePath}: ${fragments.map((f) => f.name).join(", ")}`,
72
+ );
73
+ }
74
+
75
+ if (allFragments.length === 0) {
76
+ throw new Error(
77
+ "No instantiated fragments found in any of the specified files.\n" +
78
+ "Make sure your files export instantiated fragments.",
79
+ );
80
+ }
81
+
82
+ // Build handlers mapped by mountRoute
83
+ const handlers = allFragments.map((fragment) => ({
84
+ mountRoute: fragment.mountRoute,
85
+ handler: toNodeHandler(fragment.handler.bind(fragment)),
86
+ fragment,
87
+ }));
88
+
89
+ const server = createServer((req, res) => {
90
+ const url = req.url ?? "";
91
+
92
+ for (const { mountRoute, handler } of handlers) {
93
+ if (url.startsWith(mountRoute)) {
94
+ return handler(req, res);
95
+ }
96
+ }
97
+
98
+ res.statusCode = 404;
99
+ res.setHeader("Content-Type", "application/json");
100
+ res.end(
101
+ JSON.stringify({
102
+ error: "Not Found",
103
+ availableRoutes: handlers.map((h) => h.mountRoute),
104
+ }),
105
+ );
106
+ });
107
+
108
+ server.listen(port, host, () => {
109
+ const hostStr = addressToString(server);
110
+ console.log(`\nFragno server is running on: ${hostStr}\n`);
111
+
112
+ for (const { fragment } of handlers) {
113
+ console.log(`Fragment: ${fragment.name}`);
114
+ console.log(` Mount: ${hostStr}${fragment.mountRoute}`);
115
+
116
+ const routes = fragment.routes as unknown as { method: string; path: string }[];
117
+ if (routes.length > 0) {
118
+ console.log(" Routes:");
119
+ for (const route of routes) {
120
+ console.log(` ${route.method} ${fragment.mountRoute}${route.path}`);
121
+ }
122
+ }
123
+
124
+ console.log("");
125
+ }
126
+ });
127
+ },
128
+ });
129
+
130
+ function addressToString(server: Server, protocol: "http" | "https" = "http"): string {
131
+ const addr = server.address();
132
+ if (!addr) {
133
+ throw new Error("Address invalid");
134
+ }
135
+
136
+ if (typeof addr === "string") {
137
+ return addr;
138
+ }
139
+
140
+ let host = addr.address;
141
+
142
+ if (host === "::" || host === "0.0.0.0") {
143
+ host = "localhost";
144
+ }
145
+
146
+ if (addr.family === "IPv6" && host !== "localhost") {
147
+ host = `[${host}]`;
148
+ }
149
+
150
+ return `${protocol}://${host}:${addr.port}`;
151
+ }
@@ -1,13 +1,16 @@
1
- import { isFragnoDatabase, type DatabaseAdapter, FragnoDatabase } from "@fragno-dev/db";
1
+ import { relative } from "node:path";
2
+
3
+ import { instantiatedFragmentFakeSymbol } from "@fragno-dev/core/internal/symbols";
2
4
  import {
3
5
  fragnoDatabaseAdapterNameFakeSymbol,
4
6
  fragnoDatabaseAdapterVersionFakeSymbol,
5
7
  } from "@fragno-dev/db/adapters";
6
8
  import type { AnySchema } from "@fragno-dev/db/schema";
7
- import { instantiatedFragmentFakeSymbol } from "@fragno-dev/core/internal/symbols";
9
+
8
10
  import { type FragnoInstantiatedFragment } from "@fragno-dev/core";
11
+ import { isFragnoDatabase, type DatabaseAdapter, FragnoDatabase } from "@fragno-dev/db";
12
+
9
13
  import { loadConfig } from "./load-config";
10
- import { relative } from "node:path";
11
14
 
12
15
  export async function importFragmentFile(path: string): Promise<Record<string, unknown>> {
13
16
  // Enable dry run mode for database schema extraction
@@ -149,6 +152,39 @@ function isNewFragnoInstantiatedFragment(
149
152
  );
150
153
  }
151
154
 
155
+ /**
156
+ * Finds all instantiated Fragno fragments in a module's exports.
157
+ */
158
+ export function findFragnoFragments(
159
+ targetModule: Record<string, unknown>,
160
+ ): FragnoInstantiatedFragment<
161
+ [],
162
+ unknown,
163
+ Record<string, unknown>,
164
+ Record<string, unknown>,
165
+ Record<string, unknown>,
166
+ unknown,
167
+ Record<string, unknown>
168
+ >[] {
169
+ const fragments: FragnoInstantiatedFragment<
170
+ [],
171
+ unknown,
172
+ Record<string, unknown>,
173
+ Record<string, unknown>,
174
+ Record<string, unknown>,
175
+ unknown,
176
+ Record<string, unknown>
177
+ >[] = [];
178
+
179
+ for (const [_key, value] of Object.entries(targetModule)) {
180
+ if (isNewFragnoInstantiatedFragment(value)) {
181
+ fragments.push(value);
182
+ }
183
+ }
184
+
185
+ return fragments;
186
+ }
187
+
152
188
  /**
153
189
  * Finds all FragnoDatabase instances in a module, including those embedded
154
190
  * in instantiated fragments.
@@ -168,13 +204,15 @@ export function findFragnoDatabases(
168
204
  const options = internal.options as Record<string, unknown>;
169
205
 
170
206
  // Check if this is a database fragment by looking for implicit database dependencies
171
- if (!deps["db"] || !deps["schema"]) {
207
+ if (!deps["schema"]) {
172
208
  continue;
173
209
  }
174
210
 
175
211
  const schema = deps["schema"] as AnySchema;
176
- const namespace = deps["namespace"] as string;
177
- const databaseAdapter = options["databaseAdapter"] as DatabaseAdapter | undefined;
212
+ const namespace = deps["namespace"] as string | null;
213
+ const databaseAdapter =
214
+ (deps["databaseAdapter"] as DatabaseAdapter | undefined) ??
215
+ (options["databaseAdapter"] as DatabaseAdapter | undefined);
178
216
 
179
217
  if (!databaseAdapter) {
180
218
  console.warn(
@@ -1,18 +1,26 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { stripJsonComments, convertTsconfigPathsToJitiAlias } from "./load-config";
2
+
3
3
  import { resolve } from "node:path";
4
4
 
5
+ import { stripJsonComments, convertTsconfigPathsToJitiAlias } from "./load-config";
6
+
5
7
  describe("stripJsonComments", () => {
6
8
  it("should strip single-line comments", () => {
7
9
  const input = `{
8
10
  // This is a comment
9
11
  "key": "value"
10
12
  }`;
11
- const expected = `{
12
-
13
- "key": "value"
13
+ const result = stripJsonComments(input);
14
+ expect(() => JSON.parse(result)).not.toThrow();
15
+ expect(JSON.parse(result)).toEqual({ key: "value" });
16
+ });
17
+
18
+ it("does not strip double slashes in strings", () => {
19
+ const input = `{
20
+ "key": "https://example.com/foo",
21
+ "another": "value"
14
22
  }`;
15
- expect(stripJsonComments(input)).toBe(expected);
23
+ expect(stripJsonComments(input)).toBe(input);
16
24
  });
17
25
 
18
26
  it("should strip multiple single-line comments", () => {
@@ -22,13 +30,21 @@ describe("stripJsonComments", () => {
22
30
  // Second comment
23
31
  "key2": "value2"
24
32
  }`;
25
- const expected = `{
26
-
27
- "key1": "value1",
28
-
29
- "key2": "value2"
30
- }`;
31
- expect(stripJsonComments(input)).toBe(expected);
33
+ const result = stripJsonComments(input);
34
+ expect(() => JSON.parse(result)).not.toThrow();
35
+ expect(JSON.parse(result)).toEqual({ key1: "value1", key2: "value2" });
36
+ });
37
+
38
+ it("does not strip multi line comments chars in strings", () => {
39
+ const input = `{
40
+ "compilerOptions": {
41
+ "paths": {
42
+ "~/*": ["./src/*"]
43
+ }
44
+ },
45
+ "include": ["**/*.ts", "**/*.tsx"],
46
+ }`;
47
+ expect(stripJsonComments(input)).toBe(input);
32
48
  });
33
49
 
34
50
  it("should strip multi-line comments", () => {
@@ -37,11 +53,9 @@ describe("stripJsonComments", () => {
37
53
  multi-line comment */
38
54
  "key": "value"
39
55
  }`;
40
- const expected = `{
41
-
42
- "key": "value"
43
- }`;
44
- expect(stripJsonComments(input)).toBe(expected);
56
+ const result = stripJsonComments(input);
57
+ expect(() => JSON.parse(result)).not.toThrow();
58
+ expect(JSON.parse(result)).toEqual({ key: "value" });
45
59
  });
46
60
 
47
61
  it("should strip multiple multi-line comments", () => {
@@ -52,13 +66,9 @@ describe("stripJsonComments", () => {
52
66
  spanning lines */
53
67
  "key2": "value2"
54
68
  }`;
55
- const expected = `{
56
-
57
- "key1": "value1",
58
-
59
- "key2": "value2"
60
- }`;
61
- expect(stripJsonComments(input)).toBe(expected);
69
+ const result = stripJsonComments(input);
70
+ expect(() => JSON.parse(result)).not.toThrow();
71
+ expect(JSON.parse(result)).toEqual({ key1: "value1", key2: "value2" });
62
72
  });
63
73
 
64
74
  it("should strip both single-line and multi-line comments", () => {
@@ -69,13 +79,9 @@ describe("stripJsonComments", () => {
69
79
  comment */
70
80
  "key2": "value2" // Another single line
71
81
  }`;
72
- const expected = `{
73
-
74
- "key1": "value1",
75
-
76
- "key2": "value2"
77
- }`;
78
- expect(stripJsonComments(input)).toBe(expected);
82
+ const result = stripJsonComments(input);
83
+ expect(() => JSON.parse(result)).not.toThrow();
84
+ expect(JSON.parse(result)).toEqual({ key1: "value1", key2: "value2" });
79
85
  });
80
86
 
81
87
  it("should handle strings with comment-like content", () => {
@@ -83,12 +89,12 @@ describe("stripJsonComments", () => {
83
89
  "url": "https://example.com",
84
90
  "comment": "This // is not a comment"
85
91
  }`;
86
- // Note: This is a known limitation - the simple regex approach
87
- // will strip what looks like comments even inside strings
88
- // For tsconfig.json files this is typically fine since URLs/strings
89
- // with comment syntax are rare
90
92
  const result = stripJsonComments(input);
91
- expect(result).toContain('"url": "https:');
93
+ expect(() => JSON.parse(result)).not.toThrow();
94
+ expect(JSON.parse(result)).toEqual({
95
+ url: "https://example.com",
96
+ comment: "This // is not a comment",
97
+ });
92
98
  });
93
99
 
94
100
  it("should handle empty input", () => {
@@ -1,7 +1,9 @@
1
- import { loadConfig as c12LoadConfig } from "c12";
1
+ import { constants } from "node:fs";
2
2
  import { readFile, access } from "node:fs/promises";
3
3
  import { dirname, resolve, join } from "node:path";
4
- import { constants } from "node:fs";
4
+
5
+ import { loadConfig as c12LoadConfig } from "c12";
6
+ import { stripComments } from "jsonc-parser";
5
7
 
6
8
  /**
7
9
  * Checks if a file exists using async API.
@@ -37,13 +39,7 @@ async function findTsconfig(startPath: string): Promise<string | null> {
37
39
  * Strips comments from JSONC (JSON with Comments) content.
38
40
  */
39
41
  export function stripJsonComments(jsonc: string): string {
40
- // Remove single-line comments (// ...)
41
- let result = jsonc.replace(/\/\/[^\n]*/g, "");
42
-
43
- // Remove multi-line comments (/* ... */)
44
- result = result.replace(/\/\*[\s\S]*?\*\//g, "");
45
-
46
- return result;
42
+ return stripComments(jsonc);
47
43
  }
48
44
 
49
45
  /**
package/tsconfig.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "extends": "@fragno-private/typescript-config/tsconfig.base.json",
2
+ "extends": "@fragno-private/typescript-config/tsconfig.node.json",
3
3
  "compilerOptions": {
4
4
  "outDir": "./dist",
5
5
  "rootDir": ".",
package/vitest.config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { defineConfig, mergeConfig } from "vitest/config";
2
+
2
3
  import { baseConfig } from "@fragno-private/vitest-config";
3
4
 
4
5
  export default defineConfig(