@forgerock/login-framework-cli 0.0.0-beta-20260713173318 → 0.0.0-beta-20260713183326

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/README.md CHANGED
@@ -59,7 +59,7 @@ The `core/journey/_utilities/registry/custom-registry.ts` file is generated by t
59
59
 
60
60
  Scaffolds a new custom component under `experimental/custom/callbacks/<slug>/` or `experimental/custom/stages/<slug>/`. The Vite plugin watches these directories during `pnpm dev` and regenerates `custom-registry.ts` automatically on add/remove/change.
61
61
 
62
- Run from the root of an initialized project.
62
+ Run from the root of an initialized project, or pass `--directory <path>` to target a specific project root.
63
63
 
64
64
  ```sh
65
65
  # Override the built-in NameCallback renderer
@@ -97,6 +97,64 @@ See `experimental/custom/README.md` for the full prop contract.
97
97
 
98
98
  ---
99
99
 
100
+ ### `ping-lf releases`
101
+
102
+ Lists available framework releases from GitHub.
103
+
104
+ ```sh
105
+ ping-lf releases
106
+ ```
107
+
108
+ ---
109
+
110
+ ### `ping-lf mcp`
111
+
112
+ Boots the CLI as a local **MCP server** over stdio. Exposes the same commands as typed tools that any MCP-compatible AI assistant can call directly.
113
+
114
+ #### Claude Code (project-local)
115
+
116
+ Create `.claude/mcp.json` at the root of your login project:
117
+
118
+ ```jsonc
119
+ {
120
+ "mcpServers": {
121
+ "ping-lf": {
122
+ "command": "npx",
123
+ "args": ["@forgerock/login-framework-cli", "mcp"]
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ #### Claude Desktop
130
+
131
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
132
+
133
+ ```jsonc
134
+ {
135
+ "mcpServers": {
136
+ "ping-lf": {
137
+ "command": "npx",
138
+ "args": ["@forgerock/login-framework-cli", "mcp"]
139
+ }
140
+ }
141
+ }
142
+ ```
143
+
144
+ #### Available tools
145
+
146
+ | Tool | Description |
147
+ | ------------------- | ------------------------------------ |
148
+ | `init` | Bootstrap a new project |
149
+ | `generate_callback` | Scaffold a custom callback component |
150
+ | `generate_stage` | Scaffold a custom stage component |
151
+ | `update` | Update the framework version |
152
+ | `list_releases` | List available releases from GitHub |
153
+
154
+ For `generate_callback`, `generate_stage`, and `update`, pass an absolute path via the `directory` parameter to target a specific project root. If omitted, the server's working directory is used.
155
+
156
+ ---
157
+
100
158
  ### `ping-lf update`
101
159
 
102
160
  Fetches the latest (or a specified) framework version and overwrites the core framework files while **preserving** `experimental/custom/` and project-level configs. The component registry is regenerated by the Vite plugin on the next build or dev-server start.
@@ -104,9 +162,10 @@ Fetches the latest (or a specified) framework version and overwrites the core fr
104
162
  Run from the root of an initialized project.
105
163
 
106
164
  ```sh
107
- ping-lf update # update to latest
108
- ping-lf update --tag v1.5.0 # pin to a specific release tag
109
- ping-lf update --local ../framework # use a local directory
165
+ ping-lf update # update to latest
166
+ ping-lf update --version v1.5.0 # pin to a specific version
167
+ ping-lf update --local ../framework # use a local directory
168
+ ping-lf update --directory /path/to/project # target a specific project root
110
169
  ```
111
170
 
112
171
  **What it preserves**
@@ -185,7 +244,8 @@ pnpm --filter @forgerock/login-framework-cli run dev # tsc --watch
185
244
  ```
186
245
  tools/cli/
187
246
  ├── src/
188
- │ ├── main.ts # CLI entry point (ping-lf binary)
247
+ │ ├── main.ts # Entry point forks to MCP server or CLI
248
+ │ ├── mcp.ts # MCP server (ping-lf --mcp)
189
249
  │ ├── errors.ts # Typed Effect errors
190
250
  │ ├── commands/
191
251
  │ │ ├── init.ts # ping-lf init
@@ -208,5 +268,6 @@ tools/cli/
208
268
 
209
269
  - **[Effect](https://effect.website/)** — typed async handling, composable services, declarative errors
210
270
  - **[@effect/cli](https://github.com/Effect-TS/effect/tree/main/packages/cli)** — CLI parsing and help generation
271
+ - **[@effect/ai](https://github.com/Effect-TS/effect/tree/main/packages/ai)** — MCP server (`McpServer`, `Tool`, `Toolkit`)
211
272
  - **[@effect/platform-node](https://github.com/Effect-TS/effect/tree/main/packages/platform-node)** — Node.js FileSystem, Path, HTTP client
212
273
  - **[Vitest](https://vitest.dev/)** — unit tests
@@ -1,6 +1,6 @@
1
1
  import { Command } from '@effect/cli';
2
2
  import { FileSystem, Path } from '@effect/platform';
3
- import { Schema } from 'effect';
3
+ import { Effect, Schema } from 'effect';
4
4
  import { ComponentAlreadyExistsError, InvalidComponentNameError } from '../errors.js';
5
5
  /**
6
6
  * Validates a callback component name: must be a valid name (PascalCase, alphanumeric only).
@@ -19,7 +19,8 @@ export declare const StageNameSchema: Schema.transform<Schema.filter<typeof Sche
19
19
  name: typeof Schema.String;
20
20
  slug: typeof Schema.String;
21
21
  }>>;
22
- export declare const generateCommand: Command.Command<"generate", FileSystem.FileSystem | Path.Path | import("@effect/platform/Terminal").Terminal, import("../errors.js").GeneratorVersionError | InvalidComponentNameError | ComponentAlreadyExistsError | import("@effect/platform/Error").PlatformError | import("@effect/platform/Terminal").QuitException, {
22
+ export declare function scaffoldComponent(type: 'callback' | 'stage', name: string, directory?: string): Effect.Effect<void, import("../errors.js").GeneratorVersionError | InvalidComponentNameError | ComponentAlreadyExistsError | import("../errors.js").RegistryScanError | import("@effect/platform/Error").PlatformError, FileSystem.FileSystem | Path.Path>;
23
+ export declare const generateCommand: Command.Command<"generate", FileSystem.FileSystem | Path.Path | import("@effect/platform/Terminal").Terminal, import("../errors.js").GeneratorVersionError | InvalidComponentNameError | ComponentAlreadyExistsError | import("../errors.js").RegistryScanError | import("@effect/platform/Error").PlatformError | import("@effect/platform/Terminal").QuitException, {
23
24
  readonly subcommand: import("effect/Option").Option<{
24
25
  readonly name: string;
25
26
  } | {
@@ -1,10 +1,13 @@
1
1
  import { Args, Command, Prompt } from '@effect/cli';
2
2
  import { FileSystem, Path } from '@effect/platform';
3
3
  import { Console, Effect, Schema } from 'effect';
4
+ import { existsSync } from 'node:fs';
4
5
  import nodePath from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { assertValidProject } from '../config/version.js';
7
8
  import { ComponentAlreadyExistsError, InvalidComponentNameError } from '../errors.js';
9
+ import { expandTilde } from '../services/file-system.js';
10
+ import { runRegistryScript } from '../services/registry.js';
8
11
  import { toPascalCase } from '../utils.js';
9
12
  const CUSTOM_DIR = 'experimental/custom';
10
13
  /**
@@ -53,15 +56,17 @@ export const StageNameSchema = Schema.String.pipe(Schema.filter((s) => s.length
53
56
  encode: ({ name }) => name,
54
57
  }));
55
58
  /**
56
- * Resolves the templates directory relative to this compiled file.
57
- * Build script copies templates/ dist/templates/, so from dist/commands/
58
- * the templates are one level up at dist/templates/.
59
+ * Resolves the templates directory relative to this file.
60
+ * In the compiled output (dist/src/commands/) the templates live one level up
61
+ * at dist/src/templates/. In Vitest (src/commands/) they live two levels up at
62
+ * the workspace root templates/ directory — the same fallback pattern used in mcp.ts.
59
63
  */
60
64
  function getTemplatesDir() {
61
65
  const __dirname = nodePath.dirname(fileURLToPath(import.meta.url));
62
- return nodePath.join(__dirname, '../templates');
66
+ const compiledPath = nodePath.join(__dirname, '../templates');
67
+ return existsSync(compiledPath) ? compiledPath : nodePath.join(__dirname, '../../templates');
63
68
  }
64
- function scaffoldComponent(type, name) {
69
+ export function scaffoldComponent(type, name, directory) {
65
70
  return Effect.gen(function* () {
66
71
  const fs = yield* FileSystem.FileSystem;
67
72
  const p = yield* Path.Path;
@@ -70,7 +75,7 @@ function scaffoldComponent(type, name) {
70
75
  // Stages are arbitrary AM strings with no naming convention constraint.
71
76
  const nameSchema = type === 'callback' ? CallbackNameSchema : StageNameSchema;
72
77
  const { slug } = yield* Schema.decode(nameSchema)(name).pipe(Effect.mapError(() => new InvalidComponentNameError({ name })));
73
- const cwd = process.cwd();
78
+ const cwd = p.resolve(expandTilde(directory ?? process.cwd()));
74
79
  // ── Guard: must be run from an initialized project root ───────────────
75
80
  yield* assertValidProject(cwd);
76
81
  const subDir = type === 'callback' ? 'callbacks' : 'stages';
@@ -85,17 +90,14 @@ function scaffoldComponent(type, name) {
85
90
  // ── Create component directory ─────────────────────────────────────────
86
91
  yield* fs.makeDirectory(componentDir, { recursive: true });
87
92
  // ── Copy and process template files ───────────────────────────────────
88
- // Each file name contains __COMPONENT_SLUG__; its contents use:
89
- // __COMPONENT_NAME__ raw display name (may contain spaces, e.g. "My Login Stage")
90
- // __COMPONENT_NAME_PASCAL__ — PascalCase identifier derived from slug (e.g. "MyLoginStage"),
91
- // safe for use in TypeScript symbol positions (function names etc.)
92
- // __COMPONENT_SLUG__ — kebab-case slug (e.g. "my-login-stage")
93
- const pascalName = toPascalCase(name);
93
+ // Each file name contains __COMPONENT_SLUG__; its contents contain both
94
+ // __COMPONENT_NAME__ (PascalCase) and __COMPONENT_SLUG__ (kebab-case).
94
95
  const templateFiles = yield* fs.readDirectory(templatesDir);
95
96
  const createdFiles = [];
96
97
  for (const templateFile of templateFiles) {
97
98
  const targetFileName = templateFile.replaceAll('__COMPONENT_SLUG__', slug);
98
99
  const sourceContent = yield* fs.readFileString(p.join(templatesDir, templateFile));
100
+ const pascalName = toPascalCase(name);
99
101
  const processedContent = sourceContent
100
102
  .replaceAll('__COMPONENT_NAME_PASCAL__', pascalName)
101
103
  .replaceAll('__COMPONENT_NAME__', name)
@@ -104,12 +106,14 @@ function scaffoldComponent(type, name) {
104
106
  yield* fs.writeFileString(targetPath, processedContent);
105
107
  createdFiles.push(targetPath);
106
108
  }
109
+ // ── Regenerate custom-registry.ts ────────────────────────────────────
110
+ yield* Console.log('Regenerating custom component registry...');
111
+ yield* runRegistryScript(cwd);
107
112
  // ── Print summary ──────────────────────────────────────────────────────
108
113
  yield* Console.log(`Done. ${type} component scaffolded successfully.\n\n` +
109
114
  `Files created:\n` +
110
- createdFiles.map((createdPath) => ` ${createdPath}`).join('\n') +
111
- `\n\nNext: open ${p.join(componentDir, `${slug}.svelte`)} and implement your component.\n` +
112
- `If a dev server is running, the registry will pick up the new component automatically.\n`);
115
+ createdFiles.map((f) => ` ${f}`).join('\n') +
116
+ `\n\nNext: open ${p.join(componentDir, `${slug}.svelte`)} and implement your component.\n`);
113
117
  });
114
118
  }
115
119
  // ── Subcommands ──────────────────────────────────────────────────────────────
@@ -1,8 +1,16 @@
1
1
  import { Command } from '@effect/cli';
2
2
  import { FileSystem, Path } from '@effect/platform';
3
+ import { Effect } from 'effect';
3
4
  import { DirectoryConflictError, DirectoryNotEmptyError } from '../errors.js';
4
- export declare const initCommand: Command.Command<"init", FileSystem.FileSystem | Path.Path | import("../services/release.js").Release, import("../errors.js").InvalidVersionError | import("../errors.js").ReleaseNetworkError | import("../errors.js").ReleaseParseError | import("../errors.js").ReleaseFsError | import("../errors.js").FileSystemError | DirectoryConflictError | DirectoryNotEmptyError | import("@effect/platform/Error").PlatformError, {
5
+ import type { Option } from 'effect';
6
+ export interface InitProjectOptions {
5
7
  readonly directory: string;
6
- readonly local: import("effect/Option").Option<string>;
7
- readonly tag: import("effect/Option").Option<string>;
8
+ readonly local: Option.Option<string>;
9
+ readonly version: Option.Option<string>;
10
+ }
11
+ export declare const initProject: ({ directory, local, version }: InitProjectOptions) => Effect.Effect<void, import("../errors.js").InvalidVersionError | import("../errors.js").ReleaseNetworkError | import("../errors.js").ReleaseParseError | import("../errors.js").ReleaseFsError | import("../errors.js").FileSystemError | DirectoryConflictError | DirectoryNotEmptyError | import("../errors.js").RegistryScanError | import("@effect/platform/Error").PlatformError, FileSystem.FileSystem | Path.Path | import("../services/release.js").Release>;
12
+ export declare const initCommand: Command.Command<"init", FileSystem.FileSystem | Path.Path | import("../services/release.js").Release, import("../errors.js").InvalidVersionError | import("../errors.js").ReleaseNetworkError | import("../errors.js").ReleaseParseError | import("../errors.js").ReleaseFsError | import("../errors.js").FileSystemError | DirectoryConflictError | DirectoryNotEmptyError | import("../errors.js").RegistryScanError | import("@effect/platform/Error").PlatformError, {
13
+ readonly directory: string;
14
+ readonly local: Option.Option<string>;
15
+ readonly version: Option.Option<string>;
8
16
  }>;
@@ -4,6 +4,7 @@ import { Console, Effect } from 'effect';
4
4
  import { writeVersion } from '../config/version.js';
5
5
  import { DirectoryConflictError, DirectoryNotEmptyError } from '../errors.js';
6
6
  import { copyWithExclusions, expandTilde, isFrameworkDirectory } from '../services/file-system.js';
7
+ import { runRegistryScript } from '../services/registry.js';
7
8
  import { resolveSource } from './source.js';
8
9
  /**
9
10
  * Minimal pnpm-workspace.yaml written to the customer project.
@@ -28,11 +29,7 @@ To scaffold your first custom component:
28
29
 
29
30
  Read the authoring guide: experimental/custom/README.md
30
31
  `;
31
- export const initCommand = Command.make('init', {
32
- directory: Args.text({ name: 'directory' }).pipe(Args.withDescription('Directory to initialize the project in. Use "./" for the current directory.')),
33
- local: Options.optional(Options.text('local').pipe(Options.withDescription('Path to a local framework directory. If omitted, the main branch is downloaded from GitHub.'))),
34
- tag: Options.optional(Options.text('tag').pipe(Options.withDescription('Framework release tag to download (e.g. v1.2.0). If omitted, the main branch is used.'))),
35
- }, ({ directory, local, tag }) => Effect.gen(function* () {
32
+ export const initProject = ({ directory, local, version }) => Effect.gen(function* () {
36
33
  const fs = yield* FileSystem.FileSystem;
37
34
  const path = yield* Path.Path;
38
35
  const resolvedDir = path.resolve(expandTilde(directory));
@@ -55,7 +52,7 @@ export const initCommand = Command.make('init', {
55
52
  // Effect.scoped closes the Scope that release.fetch opens, triggering
56
53
  // automatic removal of the .framework-tmp directory after copying.
57
54
  const resolvedVersion = yield* Effect.scoped(Effect.gen(function* () {
58
- const { sourceDir, resolvedVersion } = yield* resolveSource(local, tag, resolvedDir);
55
+ const { sourceDir, resolvedVersion } = yield* resolveSource(local, version, resolvedDir);
59
56
  // ── 2. Create target directory + copy framework (with exclusions) ──
60
57
  yield* Console.log('Copying framework files...');
61
58
  yield* fs.makeDirectory(resolvedDir, { recursive: true });
@@ -75,11 +72,22 @@ export const initCommand = Command.make('init', {
75
72
  yield* fs.makeDirectory(customDir, { recursive: true });
76
73
  yield* fs.writeFileString(path.join(customDir, '.gitkeep'), '');
77
74
  }), { concurrency: 'unbounded', discard: true });
78
- // ── 5. Write .generator-version ────────────────────────────────────────
75
+ // ── 5. Generate custom-registry.ts ────────────────────────────────────
76
+ // Run the canonical registry script so the file is immediately present
77
+ // with the correct CustomRegistryEntry format. The empty callbacks/ and
78
+ // stages/ dirs produce empty registries — no custom components yet.
79
+ yield* Console.log('Generating custom component registry...');
80
+ yield* runRegistryScript(resolvedDir);
81
+ // ── 6. Write .generator-version ────────────────────────────────────────
79
82
  yield* writeVersion(resolvedDir, {
80
83
  version: resolvedVersion,
81
84
  generatedAt: new Date().toISOString(),
82
85
  });
83
- // ── 6. Print next steps ───────────────────────────────────────────────
86
+ // ── 7. Print next steps ───────────────────────────────────────────────
84
87
  yield* Console.log(nextStepsMessage(directory));
85
- })).pipe(Command.withDescription('Bootstrap a new Ping Login Widget or Login App project from a GitHub release or a local framework path.'));
88
+ });
89
+ export const initCommand = Command.make('init', {
90
+ directory: Args.text({ name: 'directory' }).pipe(Args.withDescription('Directory to initialize the project in. Use "./" for the current directory.')),
91
+ local: Options.optional(Options.text('local').pipe(Options.withDescription('Path to a local framework directory. If omitted, the main branch is downloaded from GitHub.'))),
92
+ version: Options.optional(Options.text('version').pipe(Options.withDescription('Framework release tag to download (e.g. v1.2.0). If omitted, the main branch is used.'))),
93
+ }, ({ directory, local, version }) => initProject({ directory, local, version })).pipe(Command.withDescription('Bootstrap a new Ping Login Widget or Login App project from a GitHub release or a local framework path.'));
@@ -1,5 +1,7 @@
1
1
  import { Command } from '@effect/cli';
2
- export declare const updateCommand: Command.Command<"update", import("@effect/platform/FileSystem").FileSystem | import("@effect/platform/Path").Path | import("../services/release.js").Release, import("../errors.js").InvalidVersionError | import("../errors.js").ReleaseNetworkError | import("../errors.js").ReleaseParseError | import("../errors.js").ReleaseFsError | import("../errors.js").FileSystemError | import("../errors.js").GeneratorVersionError | import("@effect/platform/Error").PlatformError, {
3
- readonly local: import("effect/Option").Option<string>;
4
- readonly tag: import("effect/Option").Option<string>;
2
+ import { Option } from 'effect';
3
+ export declare const updateCommand: Command.Command<"update", import("@effect/platform/FileSystem").FileSystem | import("@effect/platform/Path").Path | import("../services/release.js").Release, import("../errors.js").InvalidVersionError | import("../errors.js").ReleaseNetworkError | import("../errors.js").ReleaseParseError | import("../errors.js").ReleaseFsError | import("../errors.js").FileSystemError | import("../errors.js").GeneratorVersionError | import("../errors.js").RegistryScanError | import("@effect/platform/Error").PlatformError, {
4
+ readonly directory: Option.Option<string>;
5
+ readonly local: Option.Option<string>;
6
+ readonly version: Option.Option<string>;
5
7
  }>;
@@ -1,13 +1,16 @@
1
1
  import { Command, Options } from '@effect/cli';
2
- import { Console, Effect } from 'effect';
2
+ import { Console, Effect, Option } from 'effect';
3
+ import nodePath from 'node:path';
3
4
  import { assertValidProject, writeVersion } from '../config/version.js';
4
- import { copyWithExclusions } from '../services/file-system.js';
5
+ import { copyWithExclusions, expandTilde } from '../services/file-system.js';
6
+ import { runRegistryScript } from '../services/registry.js';
5
7
  import { resolveSource } from './source.js';
6
8
  export const updateCommand = Command.make('update', {
9
+ directory: Options.optional(Options.text('directory').pipe(Options.withDescription('Project root directory. Defaults to the current working directory.'))),
7
10
  local: Options.optional(Options.text('local').pipe(Options.withDescription('Path to a local framework directory. If omitted, the main branch is downloaded from GitHub.'))),
8
- tag: Options.optional(Options.text('tag').pipe(Options.withDescription('Framework release tag to update to (e.g. v1.2.0). If omitted, the main branch is used.'))),
9
- }, ({ local, tag }) => Effect.gen(function* () {
10
- const cwd = process.cwd();
11
+ version: Options.optional(Options.text('version').pipe(Options.withDescription('Framework version tag to update to (e.g. v1.2.0). If omitted, the main branch is used.'))),
12
+ }, ({ local, version, directory }) => Effect.gen(function* () {
13
+ const cwd = nodePath.resolve(expandTilde(Option.getOrElse(directory, () => process.cwd())));
11
14
  // ── 1. Verify this is an initialized project ──────────────────────────
12
15
  const currentVersion = yield* assertValidProject(cwd);
13
16
  yield* Console.log(`\nUpdating project from version: ${currentVersion.version}\n`);
@@ -15,16 +18,20 @@ export const updateCommand = Command.make('update', {
15
18
  // Effect.scoped closes the Scope that release.fetch opens, triggering
16
19
  // automatic removal of the .framework-tmp directory after copying.
17
20
  const resolvedVersion = yield* Effect.scoped(Effect.gen(function* () {
18
- const { sourceDir, resolvedVersion } = yield* resolveSource(local, tag, cwd);
21
+ const { sourceDir, resolvedVersion } = yield* resolveSource(local, version, cwd);
19
22
  yield* Console.log('Copying updated framework files...');
20
23
  yield* copyWithExclusions(sourceDir, cwd);
21
24
  return resolvedVersion;
22
25
  }));
23
- // ── 3. Update .generator-version ──────────────────────────────────────
26
+ // ── 3. Regenerate custom-registry.ts ──────────────────────────────────
27
+ yield* Console.log('Regenerating custom component registry...');
28
+ yield* runRegistryScript(cwd);
29
+ // ── 4. Update .generator-version ──────────────────────────────────────
24
30
  yield* writeVersion(cwd, {
25
31
  version: resolvedVersion,
26
32
  generatedAt: new Date().toISOString(),
27
33
  });
28
34
  yield* Console.log(`\nDone. Updated from ${currentVersion.version} to ${resolvedVersion}.\n` +
35
+ 'Custom component registry regenerated.\n' +
29
36
  'Run "pnpm install" if package dependencies changed.\n');
30
37
  })).pipe(Command.withDescription('Fetch the latest (or a specified) framework version and overwrite core files while preserving experimental/custom/.'));
@@ -79,4 +79,12 @@ export declare class ComponentAlreadyExistsError extends ComponentAlreadyExistsE
79
79
  readonly path: string;
80
80
  }> {
81
81
  }
82
+ declare const RegistryScanError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
83
+ readonly _tag: "RegistryScanError";
84
+ } & Readonly<A>;
85
+ export declare class RegistryScanError extends RegistryScanError_base<{
86
+ readonly directory: string;
87
+ readonly cause?: unknown;
88
+ }> {
89
+ }
82
90
  export {};
@@ -21,3 +21,5 @@ export class InvalidComponentNameError extends Data.TaggedError('InvalidComponen
21
21
  }
22
22
  export class ComponentAlreadyExistsError extends Data.TaggedError('ComponentAlreadyExistsError') {
23
23
  }
24
+ export class RegistryScanError extends Data.TaggedError('RegistryScanError') {
25
+ }
package/dist/src/main.js CHANGED
@@ -1,34 +1,61 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from '@effect/cli';
3
3
  import { NodeContext, NodeRuntime } from '@effect/platform-node';
4
- import { Console, Effect } from 'effect';
5
- import { createRequire } from 'node:module';
4
+ import { Console, Effect, Layer } from 'effect';
5
+ import { readFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
6
8
  import { generateCommand } from './commands/generate.js';
7
9
  import { initCommand } from './commands/init.js';
8
10
  import { releasesCommand } from './commands/releases.js';
9
11
  import { updateCommand } from './commands/update.js';
12
+ import { mcpCommand, ServerLayer } from './mcp.js';
10
13
  import { GithubReleaseLayer } from './services/release.js';
11
- // Read the version from package.json at runtime so it stays in sync
12
- // with the published package version after changesets bumps it.
13
- const { version } = createRequire(import.meta.url)('../../package.json');
14
- const rootCommand = Command.make('ping-lf').pipe(Command.withDescription('CLI for initializing, scaffolding, and updating Ping Login Widget and Login App custom component projects.'), Command.withSubcommands([initCommand, generateCommand, updateCommand, releasesCommand]));
14
+ function readCliVersion() {
15
+ try {
16
+ const raw = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../../package.json'), 'utf8');
17
+ const parsed = JSON.parse(raw);
18
+ if (!parsed.version)
19
+ throw new Error('Missing "version" field in package.json');
20
+ return parsed.version;
21
+ }
22
+ catch (err) {
23
+ process.stderr.write(`Failed to read CLI version: ${String(err)}\n`);
24
+ process.exit(1);
25
+ }
26
+ }
27
+ const version = readCliVersion();
28
+ const rootCommand = Command.make('ping-lf').pipe(Command.withDescription('CLI for initializing, scaffolding, and updating Ping Login Widget and Login App custom component projects.'), Command.withSubcommands([
29
+ initCommand,
30
+ generateCommand,
31
+ updateCommand,
32
+ releasesCommand,
33
+ mcpCommand,
34
+ ]));
15
35
  const cli = Command.run(rootCommand, {
16
36
  name: 'ping-lf',
17
37
  version,
18
38
  });
19
- cli(process.argv).pipe(Effect.catchTag('DirectoryConflictError', (err) => Console.error(`\nError: "${err.path}" already contains a framework project.` +
20
- `\n Did you mean to use --local?\n` +
21
- `\n ping-lf init <new-directory> --local ${err.path}\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('DirectoryNotEmptyError', (err) => Console.error(`\nError: "${err.path}" already exists and is not empty.` +
22
- `\n Choose a different directory name, or delete the existing directory first.\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('ReleaseNetworkError', (err) => Console.error(`\nError: Could not reach GitHub to download the release.\n` +
23
- ` ${err.cause}\n\n` +
24
- ` • Check your network connection and try again.\n` +
25
- ` • Use a local path: ping-lf init <dir> --local <path>\n` +
26
- ` • Specify a tag: ping-lf init <dir> --tag v1.0.0\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('ReleaseParseError', (err) => Console.error(`\nError: Failed to parse the release data.\n ${err.cause}\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('ReleaseFsError', (err) => Console.error(`\nError: Filesystem error during release download (${err.operation}).\n ${err.cause}\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('InvalidVersionError', (err) => Console.error(`\nError: "${err.version}" is not a valid version tag.\n` +
27
- ` Expected semver format like v1.0.0.\n` +
28
- ` Use "ping-lf releases" to list available versions.\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('ReleaseNotFoundError', (err) => Console.error(`\nError: No releases found on GitHub.` +
29
- (err.cause ? `\n ${err.cause}` : '') +
30
- `\n\n • Check your network connection and try again.\n` +
31
- ` • Use a local path: ping-lf init <dir> --local <path>\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('InvalidComponentNameError', (err) => Console.error(`\nError: "${err.name}" is not a valid component name.\n` +
32
- ` Names must be PascalCase, start with an uppercase letter, and contain only letters and digits.\n` +
33
- ` Examples: MyCallback, JWTLogin, DefaultStage\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('ComponentAlreadyExistsError', (err) => Console.error(`\nError: component directory already exists: ${err.path}\n` +
34
- ` Choose a different name or delete the existing directory first.\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.provide(GithubReleaseLayer), Effect.provide(NodeContext.layer), (effect) => NodeRuntime.runMain(effect, { disableErrorReporting: true }));
39
+ // Detect `ping-lf mcp` early so we can launch the MCP server via Layer.launch,
40
+ // which cannot be composed with the @effect/cli Effect pipeline. When --help or
41
+ // -h is present we fall through to the CLI so the user still gets usage output.
42
+ const isMcpLaunch = process.argv[2] === 'mcp' && !process.argv.includes('--help') && !process.argv.includes('-h');
43
+ const program = isMcpLaunch
44
+ ? Layer.launch(ServerLayer)
45
+ : cli(process.argv).pipe(Effect.catchTag('DirectoryConflictError', (err) => Console.error(`\nError: "${err.path}" already contains a framework project.` +
46
+ `\n Did you mean to use --local?\n` +
47
+ `\n ping-lf init <new-directory> --local ${err.path}\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('DirectoryNotEmptyError', (err) => Console.error(`\nError: "${err.path}" already exists and is not empty.` +
48
+ `\n Choose a different directory name, or delete the existing directory first.\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('ReleaseNetworkError', (err) => Console.error(`\nError: Could not reach GitHub to download the release.\n` +
49
+ ` ${err.cause}\n\n` +
50
+ ` • Check your network connection and try again.\n` +
51
+ ` • Use a local path: ping-lf init <dir> --local <path>\n` +
52
+ ` Specify a version: ping-lf init <dir> --version v1.0.0\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('ReleaseParseError', (err) => Console.error(`\nError: Failed to parse the release data.\n ${err.cause}\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('ReleaseFsError', (err) => Console.error(`\nError: Filesystem error during release download (${err.operation}).\n ${err.cause}\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('InvalidVersionError', (err) => Console.error(`\nError: "${err.version}" is not a valid version tag.\n` +
53
+ ` Expected semver format like v1.0.0.\n` +
54
+ ` Use "ping-lf releases" to list available versions.\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('ReleaseNotFoundError', (err) => Console.error(`\nError: No releases found on GitHub.` +
55
+ (err.cause ? `\n ${err.cause}` : '') +
56
+ `\n\n • Check your network connection and try again.\n` +
57
+ ` • Use a local path: ping-lf init <dir> --local <path>\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('InvalidComponentNameError', (err) => Console.error(`\nError: "${err.name}" is not a valid component name.\n` +
58
+ ` Names must be PascalCase, start with an uppercase letter, and contain only letters and digits.\n` +
59
+ ` Examples: MyCallback, JWTLogin, DefaultStage\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.catchTag('ComponentAlreadyExistsError', (err) => Console.error(`\nError: component directory already exists: ${err.path}\n` +
60
+ ` Choose a different name or delete the existing directory first.\n`).pipe(Effect.andThen(Effect.die(err)))), Effect.provide(Layer.mergeAll(GithubReleaseLayer, NodeContext.layer)));
61
+ NodeRuntime.runMain(program, { disableErrorReporting: true });
@@ -0,0 +1,52 @@
1
+ import { Tool, Toolkit } from '@effect/ai';
2
+ import { Command } from '@effect/cli';
3
+ import { Layer, Schema } from 'effect';
4
+ export declare const mcpToolkit: Toolkit.Toolkit<{
5
+ readonly init: Tool.Tool<"init", {
6
+ readonly parameters: Schema.Struct<{
7
+ directory: Schema.SchemaClass<string, string, never>;
8
+ version: Schema.optional<typeof Schema.String>;
9
+ local: Schema.optional<typeof Schema.String>;
10
+ }>;
11
+ readonly success: typeof Schema.String;
12
+ readonly failure: typeof Schema.String;
13
+ readonly failureMode: "error";
14
+ }, never>;
15
+ readonly generate_callback: Tool.Tool<"generate_callback", {
16
+ readonly parameters: Schema.Struct<{
17
+ name: Schema.SchemaClass<string, string, never>;
18
+ directory: Schema.optional<typeof Schema.String>;
19
+ }>;
20
+ readonly success: typeof Schema.String;
21
+ readonly failure: typeof Schema.String;
22
+ readonly failureMode: "error";
23
+ }, never>;
24
+ readonly generate_stage: Tool.Tool<"generate_stage", {
25
+ readonly parameters: Schema.Struct<{
26
+ name: Schema.SchemaClass<string, string, never>;
27
+ directory: Schema.optional<typeof Schema.String>;
28
+ }>;
29
+ readonly success: typeof Schema.String;
30
+ readonly failure: typeof Schema.String;
31
+ readonly failureMode: "error";
32
+ }, never>;
33
+ readonly update: Tool.Tool<"update", {
34
+ readonly parameters: Schema.Struct<{
35
+ directory: Schema.optional<typeof Schema.String>;
36
+ version: Schema.optional<typeof Schema.String>;
37
+ local: Schema.optional<typeof Schema.String>;
38
+ }>;
39
+ readonly success: typeof Schema.String;
40
+ readonly failure: typeof Schema.String;
41
+ readonly failureMode: "error";
42
+ }, never>;
43
+ readonly list_releases: Tool.Tool<"list_releases", {
44
+ readonly parameters: Schema.Struct<{}>;
45
+ readonly success: typeof Schema.String;
46
+ readonly failure: typeof Schema.String;
47
+ readonly failureMode: "error";
48
+ }, never>;
49
+ }>;
50
+ declare const ServerLayer: Layer.Layer<never, never, never>;
51
+ export { ServerLayer };
52
+ export declare const mcpCommand: Command.Command<"mcp", never, never, {}>;
@@ -0,0 +1,182 @@
1
+ import { McpServer, Tool, Toolkit } from '@effect/ai';
2
+ import { Command } from '@effect/cli';
3
+ import { NodeContext, NodeSink, NodeStream } from '@effect/platform-node';
4
+ import { Cause, Effect, Layer, Logger, Option, Schema } from 'effect';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { scaffoldComponent } from './commands/generate.js';
9
+ import { initProject } from './commands/init.js';
10
+ import { resolveSource } from './commands/source.js';
11
+ import { assertValidProject, writeVersion } from './config/version.js';
12
+ import { copyWithExclusions, expandTilde } from './services/file-system.js';
13
+ import { runRegistryScript } from './services/registry.js';
14
+ import { GithubReleaseLayer, Release } from './services/release.js';
15
+ // Resolve package.json from either dist/src/ (compiled) or src/ (Vitest).
16
+ const __dir = dirname(fileURLToPath(import.meta.url));
17
+ const pkgPath = existsSync(resolve(__dir, '../../package.json'))
18
+ ? resolve(__dir, '../../package.json')
19
+ : resolve(__dir, '../package.json');
20
+ const { version } = JSON.parse(readFileSync(pkgPath, 'utf8'));
21
+ // ── Shared error formatter ────────────────────────────────────────────────────
22
+ function formatError(cause) {
23
+ const err = Cause.failureOption(cause);
24
+ if (Option.isSome(err)) {
25
+ const e = err.value;
26
+ switch (e._tag) {
27
+ case 'InvalidVersionError':
28
+ return `Invalid version "${e['version']}". Expected semver format like v1.0.0.`;
29
+ case 'ReleaseNetworkError':
30
+ return `Network error reaching GitHub: ${e['cause']}`;
31
+ case 'ReleaseParseError':
32
+ return `Failed to parse release data: ${e['cause']}`;
33
+ case 'ReleaseFsError':
34
+ return `Filesystem error during ${e['operation']}: ${e['cause']}`;
35
+ case 'ReleaseNotFoundError':
36
+ return `No releases found on GitHub${e['cause'] ? `: ${e['cause']}` : ''}.`;
37
+ case 'FileSystemError':
38
+ return `Filesystem error (${e['operation']}) at "${e['path']}": ${e['cause']}`;
39
+ case 'GeneratorVersionError':
40
+ return `Generator version error: ${e['message']}${e['path'] ? ` (${e['path']})` : ''}`;
41
+ case 'RegistryScanError':
42
+ return `Registry scan failed in "${e['directory']}": ${e['cause']}`;
43
+ case 'DirectoryConflictError':
44
+ return `"${e['path']}" already contains a framework project. Use local path instead.`;
45
+ case 'DirectoryNotEmptyError':
46
+ return `"${e['path']}" already exists and is not empty.`;
47
+ case 'InvalidComponentNameError':
48
+ return `"${e['name']}" is not a valid component name. Use PascalCase (e.g. MyCallback).`;
49
+ case 'ComponentAlreadyExistsError':
50
+ return `Component directory already exists: ${e['path']}`;
51
+ default:
52
+ try {
53
+ return `Error: ${JSON.stringify(e, (_k, v) => v instanceof Error ? { message: v.message, name: v.name } : v)}`;
54
+ }
55
+ catch {
56
+ return `Error: ${String(e)}`;
57
+ }
58
+ }
59
+ }
60
+ return `Unexpected error: ${Cause.pretty(cause)}`;
61
+ }
62
+ const toolLayer = Layer.mergeAll(GithubReleaseLayer, NodeContext.layer);
63
+ const catchToolErrors = (effect) => effect.pipe(Effect.provide(toolLayer), Effect.catchAll((err) => Effect.fail(formatError(Cause.fail(err)))));
64
+ // ── Tool definitions ─────────────────────────────────────────────────────────
65
+ const InitTool = Tool.make('init', {
66
+ description: 'Bootstrap a new Ping Login Widget / Login App project from a GitHub release or local path.',
67
+ parameters: {
68
+ directory: Schema.String.annotations({
69
+ description: 'Directory to initialize the project in (e.g. "my-project" or "./")',
70
+ }),
71
+ version: Schema.optional(Schema.String).annotations({
72
+ description: 'Framework version tag to download (e.g. v1.2.0). Defaults to latest.',
73
+ }),
74
+ local: Schema.optional(Schema.String).annotations({
75
+ description: 'Path to a local framework directory instead of downloading from GitHub.',
76
+ }),
77
+ },
78
+ success: Schema.String,
79
+ failure: Schema.String,
80
+ })
81
+ .annotate(Tool.Destructive, true)
82
+ .annotate(Tool.OpenWorld, true)
83
+ .annotate(Tool.Idempotent, false);
84
+ const directoryParam = Schema.optional(Schema.String).annotations({
85
+ description: 'Absolute path to the initialized project root. Defaults to the current working directory.',
86
+ });
87
+ const GenerateCallbackTool = Tool.make('generate_callback', {
88
+ description: 'Scaffold a new custom callback component under experimental/custom/callbacks/. Run from an initialized project root.',
89
+ parameters: {
90
+ name: Schema.String.annotations({
91
+ description: 'PascalCase component name (e.g. MyCallback). Must match the AM callback type string.',
92
+ }),
93
+ directory: directoryParam,
94
+ },
95
+ success: Schema.String,
96
+ failure: Schema.String,
97
+ })
98
+ .annotate(Tool.Destructive, true)
99
+ .annotate(Tool.OpenWorld, false)
100
+ .annotate(Tool.Idempotent, false);
101
+ const GenerateStageTool = Tool.make('generate_stage', {
102
+ description: 'Scaffold a new custom stage component under experimental/custom/stages/. Run from an initialized project root.',
103
+ parameters: {
104
+ name: Schema.String.annotations({
105
+ description: 'Stage name as configured on the AM journey Page Node (e.g. "DefaultLogin" or "My Login Stage").',
106
+ }),
107
+ directory: directoryParam,
108
+ },
109
+ success: Schema.String,
110
+ failure: Schema.String,
111
+ })
112
+ .annotate(Tool.Destructive, true)
113
+ .annotate(Tool.OpenWorld, false)
114
+ .annotate(Tool.Idempotent, false);
115
+ const UpdateTool = Tool.make('update', {
116
+ description: 'Fetch the latest (or specified) framework version and overwrite core files while preserving experimental/custom/. Run from an initialized project root.',
117
+ parameters: {
118
+ directory: directoryParam,
119
+ version: Schema.optional(Schema.String).annotations({
120
+ description: 'Framework version tag to update to (e.g. v1.2.0). Defaults to latest.',
121
+ }),
122
+ local: Schema.optional(Schema.String).annotations({
123
+ description: 'Path to a local framework directory instead of downloading from GitHub.',
124
+ }),
125
+ },
126
+ success: Schema.String,
127
+ failure: Schema.String,
128
+ })
129
+ .annotate(Tool.Destructive, true)
130
+ .annotate(Tool.OpenWorld, true)
131
+ .annotate(Tool.Idempotent, false);
132
+ const ListReleasesTool = Tool.make('list_releases', {
133
+ description: 'List available Login Framework releases from GitHub.',
134
+ parameters: {},
135
+ success: Schema.String,
136
+ failure: Schema.String,
137
+ })
138
+ .annotate(Tool.Readonly, true)
139
+ .annotate(Tool.Destructive, false)
140
+ .annotate(Tool.OpenWorld, true)
141
+ .annotate(Tool.Idempotent, true);
142
+ // ── Exported toolkit (used in tests) ─────────────────────────────────────────
143
+ export const mcpToolkit = Toolkit.make(InitTool, GenerateCallbackTool, GenerateStageTool, UpdateTool, ListReleasesTool);
144
+ // ── Tool handlers ─────────────────────────────────────────────────────────────
145
+ const handlerLayer = mcpToolkit.toLayer({
146
+ init: ({ directory, version: ver, local }) => catchToolErrors(initProject({
147
+ directory,
148
+ version: Option.fromNullable(ver),
149
+ local: Option.fromNullable(local),
150
+ }).pipe(Effect.map(() => `Project initialized successfully in "${directory}".`))),
151
+ generate_callback: ({ name, directory }) => catchToolErrors(scaffoldComponent('callback', name, directory).pipe(Effect.map(() => `Callback component "${name}" scaffolded successfully.`))),
152
+ generate_stage: ({ name, directory }) => catchToolErrors(scaffoldComponent('stage', name, directory).pipe(Effect.map(() => `Stage component "${name}" scaffolded successfully.`))),
153
+ update: ({ version: ver, local, directory }) => catchToolErrors(Effect.gen(function* () {
154
+ const cwd = resolve(expandTilde(directory ?? process.cwd()));
155
+ const currentVersion = yield* assertValidProject(cwd);
156
+ const resolvedVersion = yield* Effect.scoped(Effect.gen(function* () {
157
+ const { sourceDir, resolvedVersion } = yield* resolveSource(Option.fromNullable(local), Option.fromNullable(ver), cwd);
158
+ yield* copyWithExclusions(sourceDir, cwd);
159
+ return resolvedVersion;
160
+ }));
161
+ yield* runRegistryScript(cwd);
162
+ yield* writeVersion(cwd, {
163
+ version: resolvedVersion,
164
+ generatedAt: new Date().toISOString(),
165
+ });
166
+ return `Updated from ${currentVersion.version} to ${resolvedVersion}. Run "pnpm install" if dependencies changed.`;
167
+ })),
168
+ list_releases: () => catchToolErrors(Release.pipe(Effect.flatMap((release) => release.listReleases()), Effect.map((releases) => releases.map(({ tag, publishedAt }) => `${tag.padEnd(10)} ${publishedAt}`).join('\n')))),
169
+ });
170
+ // ── Server layer ──────────────────────────────────────────────────────────────
171
+ const ServerLayer = McpServer.toolkit(mcpToolkit).pipe(Layer.provide(handlerLayer), Layer.provide(Layer.mergeAll(McpServer.layerStdio({
172
+ name: 'ping-lf',
173
+ version,
174
+ stdin: NodeStream.stdin,
175
+ stdout: NodeSink.stdout,
176
+ }), NodeContext.layer, Logger.add(Logger.prettyLogger({ stderr: true })))));
177
+ export { ServerLayer };
178
+ // mcpCommand is registered only so `ping-lf --help` lists `mcp` as a subcommand.
179
+ // The actual MCP dispatch happens in main.ts via Layer.launch(ServerLayer) at the
180
+ // top level, outside the @effect/cli command handler where Layer.launch would
181
+ // nest a second fiber scope inside the CLI's already-running one.
182
+ export const mcpCommand = Command.make('mcp', {}, () => Effect.void).pipe(Command.withDescription('Start as an MCP server over stdio.'));
@@ -0,0 +1,22 @@
1
+ import { FileSystem, Path } from '@effect/platform';
2
+ import { Effect } from 'effect';
3
+ import { RegistryScanError } from '../errors.js';
4
+ type ComponentType = 'stage' | 'callback';
5
+ /** Extracts names of all `export let` prop declarations from a Svelte component's `<script>` block. */
6
+ export declare function parseAcceptedProps(content: string): string[];
7
+ /** Parses and validates the leading `<!-- @component -->` block from a Svelte file. */
8
+ export declare const parseComponentHeader: (filePath: string, content: string) => Effect.Effect<{
9
+ type: ComponentType;
10
+ name: string;
11
+ }, RegistryScanError>;
12
+ /**
13
+ * Scans `experimental/custom/stages/` and `experimental/custom/callbacks/` for
14
+ * `@component`-annotated Svelte files and writes
15
+ * `core/journey/_utilities/custom-registry.ts`.
16
+ *
17
+ * All I/O runs in-process via the platform `FileSystem` service — no subprocess
18
+ * spawning. Validation errors across multiple components are collected and
19
+ * reported together.
20
+ */
21
+ export declare const runRegistryScript: (projectDir: string) => Effect.Effect<void, RegistryScanError, FileSystem.FileSystem | Path.Path>;
22
+ export {};
@@ -0,0 +1,169 @@
1
+ import { FileSystem, Path } from '@effect/platform';
2
+ import { Console, Effect } from 'effect';
3
+ import { RegistryScanError } from '../errors.js';
4
+ // --------------------------------------------------------------------------
5
+ // Helpers (exported for testing)
6
+ // --------------------------------------------------------------------------
7
+ /** Extracts names of all `export let` prop declarations from a Svelte component's `<script>` block. */
8
+ export function parseAcceptedProps(content) {
9
+ const scriptMatch = content.match(/<script[^>]*>([\s\S]*?)<\/script>/);
10
+ if (!scriptMatch)
11
+ return [];
12
+ const props = [];
13
+ const regex = /export\s+let\s+(\w+)/g;
14
+ let m;
15
+ while ((m = regex.exec(scriptMatch[1])) !== null)
16
+ props.push(m[1]);
17
+ return props;
18
+ }
19
+ function toPascalCase(str) {
20
+ return str
21
+ .replace(/[^a-zA-Z0-9]+(.)/g, (_, chr) => chr.toUpperCase())
22
+ .replace(/^(.)/, (_, chr) => chr.toUpperCase());
23
+ }
24
+ /** Parses and validates the leading `<!-- @component -->` block from a Svelte file. */
25
+ export const parseComponentHeader = (filePath, content) => {
26
+ const fail = (cause) => Effect.fail(new RegistryScanError({ directory: filePath, cause }));
27
+ const commentMatch = content.match(/^<!--([\s\S]*?)-->/);
28
+ if (!commentMatch)
29
+ return fail('Missing @component header. Every custom component must begin with:\n' +
30
+ '<!--\n @component\n Type: stage|callback\n Name: <ComponentName>\n -->');
31
+ const block = commentMatch[1];
32
+ if (!block.includes('@component'))
33
+ return fail('Missing "@component" tag in the opening comment.');
34
+ const typeMatch = block.match(/Type:\s*(\S+)/);
35
+ if (!typeMatch)
36
+ return fail('Missing "Type:" field in @component header. Expected: Type: stage or Type: callback');
37
+ const rawType = typeMatch[1].toLowerCase();
38
+ if (rawType !== 'stage' && rawType !== 'callback')
39
+ return fail(`Invalid Type value "${typeMatch[1]}". Must be "stage" or "callback".`);
40
+ const nameMatch = block.match(/Name:\s*(.+)/);
41
+ if (!nameMatch)
42
+ return fail('Missing "Name:" field in @component header. Expected: Name: <ComponentName>');
43
+ return Effect.succeed({ type: rawType, name: nameMatch[1].trim() });
44
+ };
45
+ // --------------------------------------------------------------------------
46
+ // File scanning
47
+ // --------------------------------------------------------------------------
48
+ const findSvelteFiles = (fs, path, dir) => fs.exists(dir).pipe(Effect.orElseSucceed(() => false), Effect.flatMap((exists) => !exists
49
+ ? Effect.succeed([])
50
+ : fs.readDirectory(dir).pipe(Effect.mapError((cause) => new RegistryScanError({ directory: dir, cause })), Effect.flatMap((entries) => Effect.forEach(entries, (entry) => {
51
+ const fullPath = path.join(dir, entry);
52
+ return fs.stat(fullPath).pipe(Effect.mapError((cause) => new RegistryScanError({ directory: dir, cause })), Effect.flatMap((stat) => stat.type === 'Directory'
53
+ ? findSvelteFiles(fs, path, fullPath)
54
+ : Effect.succeed(entry.endsWith('.svelte') && !entry.endsWith('.story.svelte')
55
+ ? [fullPath]
56
+ : [])));
57
+ }, { concurrency: 'unbounded' })), Effect.map((nested) => nested.flat()))));
58
+ const scanDirectory = (fs, path, dir, expectedType) => findSvelteFiles(fs, path, dir).pipe(Effect.flatMap((files) => Effect.validateAll(files, (filePath) => fs.readFileString(filePath).pipe(Effect.mapError((cause) => new RegistryScanError({ directory: filePath, cause })), Effect.flatMap((content) => parseComponentHeader(filePath, content).pipe(Effect.flatMap(({ type, name }) => type !== expectedType
59
+ ? Effect.fail(new RegistryScanError({
60
+ directory: filePath,
61
+ cause: `Component in /experimental/custom/${expectedType}s/ declares Type: "${type}". Must declare Type: ${expectedType}`,
62
+ }))
63
+ : Effect.succeed({
64
+ filePath,
65
+ name,
66
+ type,
67
+ acceptedProps: parseAcceptedProps(content),
68
+ })))))).pipe(Effect.mapError((errors) => new RegistryScanError({
69
+ directory: dir,
70
+ cause: errors.map((e) => String(e.cause)).join('\n'),
71
+ })))));
72
+ // --------------------------------------------------------------------------
73
+ // Registry content builder (pure)
74
+ // --------------------------------------------------------------------------
75
+ function buildRegistryContent(path, registryDir, stageComponents, callbackComponents) {
76
+ const toEntry = (prefix) => ({ filePath, name, acceptedProps }) => {
77
+ const relPath = path.relative(registryDir, filePath).replace(/\\/g, '/');
78
+ const importPath = relPath.startsWith('.') ? relPath : `./${relPath}`;
79
+ return { varName: `${prefix}${toPascalCase(name)}`, importPath, name, acceptedProps };
80
+ };
81
+ const stageEntries = stageComponents.map(toEntry('Stage'));
82
+ const callbackEntries = callbackComponents.map(toEntry('Callback'));
83
+ const lines = [
84
+ `/**`,
85
+ ` * AUTO-GENERATED — do not edit by hand.`,
86
+ ` * Run \`pnpm build:widget\` or \`pnpm --filter @forgerock/login-widget exec vite build\` to regenerate.`,
87
+ ` *`,
88
+ ` * Source: /experimental/custom/stages/ and /experimental/custom/callbacks/`,
89
+ ` */`,
90
+ ``,
91
+ `import type { Component } from 'svelte';`,
92
+ ``,
93
+ `export interface CustomRegistryEntry {`,
94
+ ` component: Component;`,
95
+ ` /** Props declared via \`export let\` in the component — only these are forwarded by the mapper. */`,
96
+ ` acceptedProps: string[];`,
97
+ `}`,
98
+ ``,
99
+ ];
100
+ if (stageEntries.length > 0) {
101
+ lines.push(`// Stage overrides / extensions`);
102
+ for (const { varName, importPath } of stageEntries)
103
+ lines.push(`import ${varName} from '${importPath}';`);
104
+ lines.push(``);
105
+ }
106
+ if (callbackEntries.length > 0) {
107
+ lines.push(`// Callback overrides / extensions`);
108
+ for (const { varName, importPath } of callbackEntries)
109
+ lines.push(`import ${varName} from '${importPath}';`);
110
+ lines.push(``);
111
+ }
112
+ lines.push(`export const customStageRegistry: Record<string, CustomRegistryEntry> = {`);
113
+ for (const { varName, name, acceptedProps } of stageEntries)
114
+ lines.push(` ${JSON.stringify(name)}: { component: ${varName}, acceptedProps: ${JSON.stringify(acceptedProps)} },`);
115
+ lines.push(`};`);
116
+ lines.push(``);
117
+ lines.push(`export const customCallbackRegistry: Record<string, CustomRegistryEntry> = {`);
118
+ for (const { varName, name, acceptedProps } of callbackEntries)
119
+ lines.push(` ${JSON.stringify(name)}: { component: ${varName}, acceptedProps: ${JSON.stringify(acceptedProps)} },`);
120
+ lines.push(`};`);
121
+ lines.push(``);
122
+ return lines.join('\n');
123
+ }
124
+ // --------------------------------------------------------------------------
125
+ // Public API
126
+ // --------------------------------------------------------------------------
127
+ /**
128
+ * Scans `experimental/custom/stages/` and `experimental/custom/callbacks/` for
129
+ * `@component`-annotated Svelte files and writes
130
+ * `core/journey/_utilities/custom-registry.ts`.
131
+ *
132
+ * All I/O runs in-process via the platform `FileSystem` service — no subprocess
133
+ * spawning. Validation errors across multiple components are collected and
134
+ * reported together.
135
+ */
136
+ export const runRegistryScript = (projectDir) => Effect.gen(function* () {
137
+ const fs = yield* FileSystem.FileSystem;
138
+ const path = yield* Path.Path;
139
+ const stageDir = path.join(projectDir, 'experimental', 'custom', 'stages');
140
+ const callbackDir = path.join(projectDir, 'experimental', 'custom', 'callbacks');
141
+ const registryDir = path.join(projectDir, 'core', 'journey', '_utilities', 'registry');
142
+ const registryPath = path.join(registryDir, 'custom-registry.ts');
143
+ const [stageComponents, callbackComponents] = yield* Effect.all([
144
+ scanDirectory(fs, path, stageDir, 'stage'),
145
+ scanDirectory(fs, path, callbackDir, 'callback'),
146
+ ], { concurrency: 'unbounded' });
147
+ const content = buildRegistryContent(path, registryDir, stageComponents, callbackComponents);
148
+ yield* fs
149
+ .makeDirectory(registryDir, { recursive: true })
150
+ .pipe(Effect.mapError((cause) => new RegistryScanError({ directory: registryDir, cause })));
151
+ yield* fs
152
+ .writeFileString(registryPath, content)
153
+ .pipe(Effect.mapError((cause) => new RegistryScanError({ directory: registryPath, cause })));
154
+ const total = stageComponents.length + callbackComponents.length;
155
+ if (total === 0) {
156
+ yield* Console.log(`custom-registry.ts generated (no custom components found — registries are empty)`);
157
+ }
158
+ else {
159
+ yield* Console.log(`custom-registry.ts generated:`);
160
+ if (stageComponents.length > 0)
161
+ yield* Console.log(` Stages (${stageComponents.length}): ${stageComponents
162
+ .map((c) => c.name)
163
+ .join(', ')}`);
164
+ if (callbackComponents.length > 0)
165
+ yield* Console.log(` Callbacks (${callbackComponents.length}): ${callbackComponents
166
+ .map((c) => c.name)
167
+ .join(', ')}`);
168
+ }
169
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgerock/login-framework-cli",
3
- "version": "0.0.0-beta-20260713173318",
3
+ "version": "0.0.0-beta-20260713183326",
4
4
  "description": "CLI tool for scaffolding and managing Ping Login Widget and Login App custom component projects",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,6 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
+ "@effect/ai": "^0.35.0",
39
40
  "@effect/cli": "^0.75.0",
40
41
  "@effect/platform": "^0.96.0",
41
42
  "@effect/platform-node": "^0.106.0",