@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 +66 -5
- package/dist/src/commands/generate.d.ts +3 -2
- package/dist/src/commands/generate.js +19 -15
- package/dist/src/commands/init.d.ts +11 -3
- package/dist/src/commands/init.js +17 -9
- package/dist/src/commands/update.d.ts +5 -3
- package/dist/src/commands/update.js +14 -7
- package/dist/src/errors.d.ts +8 -0
- package/dist/src/errors.js +2 -0
- package/dist/src/main.js +49 -22
- package/dist/src/mcp.d.ts +52 -0
- package/dist/src/mcp.js +182 -0
- package/dist/src/services/registry.d.ts +22 -0
- package/dist/src/services/registry.js +169 -0
- package/package.json +2 -1
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
|
|
108
|
-
ping-lf update --
|
|
109
|
-
ping-lf update --local ../framework
|
|
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 #
|
|
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
|
|
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
|
|
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
|
|
89
|
-
//
|
|
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((
|
|
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
|
-
|
|
5
|
+
import type { Option } from 'effect';
|
|
6
|
+
export interface InitProjectOptions {
|
|
5
7
|
readonly directory: string;
|
|
6
|
-
readonly local:
|
|
7
|
-
readonly
|
|
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
|
|
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,
|
|
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.
|
|
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
|
-
// ──
|
|
86
|
+
// ── 7. Print next steps ───────────────────────────────────────────────
|
|
84
87
|
yield* Console.log(nextStepsMessage(directory));
|
|
85
|
-
})
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
readonly
|
|
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
|
-
|
|
9
|
-
}, ({ local,
|
|
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,
|
|
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.
|
|
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/.'));
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/src/errors.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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, {}>;
|
package/dist/src/mcp.js
ADDED
|
@@ -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-
|
|
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",
|