@forgerock/login-framework-cli 0.0.0-beta-20260429204418
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/scripts/generate-registry.d.ts +2 -0
- package/dist/scripts/generate-registry.js +20 -0
- package/dist/src/commands/generate.d.ts +28 -0
- package/dist/src/commands/generate.js +137 -0
- package/dist/src/commands/init.d.ts +8 -0
- package/dist/src/commands/init.js +95 -0
- package/dist/src/commands/releases.d.ts +3 -0
- package/dist/src/commands/releases.js +12 -0
- package/dist/src/commands/source.d.ts +22 -0
- package/dist/src/commands/source.js +24 -0
- package/dist/src/commands/update.d.ts +5 -0
- package/dist/src/commands/update.js +36 -0
- package/dist/src/config/exclusions.d.ts +1 -0
- package/dist/src/config/exclusions.js +62 -0
- package/dist/src/config/version.d.ts +26 -0
- package/dist/src/config/version.js +41 -0
- package/dist/src/errors.d.ts +90 -0
- package/dist/src/errors.js +25 -0
- package/dist/src/main.d.ts +2 -0
- package/dist/src/main.js +34 -0
- package/dist/src/services/file-system.d.ts +13 -0
- package/dist/src/services/file-system.js +64 -0
- package/dist/src/services/registry.d.ts +22 -0
- package/dist/src/services/registry.js +169 -0
- package/dist/src/services/release.d.ts +27 -0
- package/dist/src/services/release.js +86 -0
- package/dist/src/templates/callback/__COMPONENT_SLUG__.svelte +73 -0
- package/dist/src/templates/callback/__COMPONENT_SLUG__.utilities.test.ts +9 -0
- package/dist/src/templates/callback/__COMPONENT_SLUG__.utilities.ts +13 -0
- package/dist/src/templates/stage/__COMPONENT_SLUG__.svelte +148 -0
- package/dist/src/templates/stage/__COMPONENT_SLUG__.utilities.test.ts +9 -0
- package/dist/src/templates/stage/__COMPONENT_SLUG__.utilities.ts +13 -0
- package/dist/src/utils.d.ts +2 -0
- package/dist/src/utils.js +4 -0
- package/dist/templates/callback/__COMPONENT_SLUG__.utilities.d.ts +10 -0
- package/dist/templates/callback/__COMPONENT_SLUG__.utilities.js +12 -0
- package/dist/templates/callback/__COMPONENT_SLUG__.utilities.test.d.ts +1 -0
- package/dist/templates/callback/__COMPONENT_SLUG__.utilities.test.js +7 -0
- package/dist/templates/stage/__COMPONENT_SLUG__.utilities.d.ts +10 -0
- package/dist/templates/stage/__COMPONENT_SLUG__.utilities.js +12 -0
- package/dist/templates/stage/__COMPONENT_SLUG__.utilities.test.d.ts +1 -0
- package/dist/templates/stage/__COMPONENT_SLUG__.utilities.test.js +7 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2025 Ping Identity Corporation (formerly ForgeRock AS)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# `ping-lf` — Ping Login Framework CLI
|
|
2
|
+
|
|
3
|
+
CLI for initializing, scaffolding, and updating Ping Login Widget and Login App custom component projects.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- **Node.js** >= 20.0.0
|
|
8
|
+
- **pnpm** >= 9
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install -g @forgerock/login-framework-cli
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or run directly without installing:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx @forgerock/login-framework-cli init my-project
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
### `ping-lf init <directory>`
|
|
25
|
+
|
|
26
|
+
Bootstraps a new Login Widget or Login App project into `<directory>` by fetching the latest framework release from GitHub, copying it with safe exclusions, and setting up the custom component scaffolding.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
ping-lf init my-login-project
|
|
30
|
+
cd my-login-project
|
|
31
|
+
pnpm install
|
|
32
|
+
cp .env.example .env # fill in your ForgeRock AM details
|
|
33
|
+
pnpm dev
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Options**
|
|
37
|
+
|
|
38
|
+
| Flag | Description |
|
|
39
|
+
| ----------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `--version <tag>` | Download a specific release (e.g. `v1.2.0`). Defaults to latest. |
|
|
41
|
+
| `--local <path>` | Use a local framework directory instead of downloading from GitHub. Useful for development or air-gapped environments. |
|
|
42
|
+
|
|
43
|
+
**What it does**
|
|
44
|
+
|
|
45
|
+
1. Downloads the framework archive from GitHub Releases (or copies from `--local`)
|
|
46
|
+
2. Copies framework files, excluding build artifacts, secrets, and `tools/`
|
|
47
|
+
3. Writes a minimal `pnpm-workspace.yaml`
|
|
48
|
+
4. Creates `experimental/custom/callbacks/` and `experimental/custom/stages/` with `.gitkeep` placeholders
|
|
49
|
+
5. Copies the component authoring guide to `experimental/custom/README.md`
|
|
50
|
+
6. Seeds an empty `core/journey/_utilities/custom-registry.ts`
|
|
51
|
+
7. Injects the custom registry Vite plugin into widget and app configs
|
|
52
|
+
8. Writes `.generator-version` to track the framework version in use
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
### `ping-lf generate callback <Name>`
|
|
57
|
+
|
|
58
|
+
### `ping-lf generate stage <Name>`
|
|
59
|
+
|
|
60
|
+
Scaffolds a new custom component under `experimental/custom/callbacks/<slug>/` or `experimental/custom/stages/<slug>/`, then regenerates `custom-registry.ts`.
|
|
61
|
+
|
|
62
|
+
Run from the root of an initialized project.
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
# Override the built-in NameCallback renderer
|
|
66
|
+
ping-lf generate callback NameCallback
|
|
67
|
+
|
|
68
|
+
# Override the DefaultLogin stage layout
|
|
69
|
+
ping-lf generate stage DefaultLogin
|
|
70
|
+
|
|
71
|
+
# Create a brand-new callback type (paired with a custom AM node)
|
|
72
|
+
ping-lf generate callback MyTelemetryCallback
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**What it creates** (example for `ping-lf generate callback NameCallback`):
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
experimental/custom/callbacks/name-callback/
|
|
79
|
+
├── name-callback.svelte # component implementation — edit this
|
|
80
|
+
└── name-callback.utilities.ts # helper functions (optional)
|
|
81
|
+
└── name-callback.utilities.test.ts # unit test stub
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The `@component` header in the generated `.svelte` file declares the type and name. The pre-build script and `ping-lf` both read this header to register the component:
|
|
85
|
+
|
|
86
|
+
```svelte
|
|
87
|
+
<!--
|
|
88
|
+
@component
|
|
89
|
+
Type: callback
|
|
90
|
+
Name: NameCallback
|
|
91
|
+
-->
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Setting `Name` to an existing default (e.g. `NameCallback`, `DefaultLogin`) **overrides** that component. Setting it to a new name **extends** the framework with a custom type.
|
|
95
|
+
|
|
96
|
+
See `experimental/custom/README.md` for the full prop contract.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
### `ping-lf update`
|
|
101
|
+
|
|
102
|
+
Fetches the latest (or a specified) framework version and overwrites the core framework files while **preserving** `experimental/custom/` and project-level configs. Regenerates the component registry after the update.
|
|
103
|
+
|
|
104
|
+
Run from the root of an initialized project.
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
ping-lf update # update to latest
|
|
108
|
+
ping-lf update --version v1.5.0 # pin to a specific version
|
|
109
|
+
ping-lf update --local ../framework # use a local directory
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**What it preserves**
|
|
113
|
+
|
|
114
|
+
- `experimental/custom/` — all custom components are untouched
|
|
115
|
+
- `pnpm-workspace.yaml` — your workspace config is not overwritten
|
|
116
|
+
- `.env` files — excluded from the copy
|
|
117
|
+
|
|
118
|
+
**After updating**, run `pnpm install` if the framework's package dependencies changed.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Project layout (after `init`)
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
my-login-project/
|
|
126
|
+
├── packages/login-widget/ # @forgerock/login-widget
|
|
127
|
+
├── apps/login-app/ # SvelteKit dev/docs app
|
|
128
|
+
├── core/ # Shared stores, components, journey logic
|
|
129
|
+
│ └── journey/_utilities/
|
|
130
|
+
│ └── custom-registry.ts # AUTO-GENERATED — do not edit
|
|
131
|
+
├── experimental/
|
|
132
|
+
│ └── custom/
|
|
133
|
+
│ ├── README.md # Component authoring guide
|
|
134
|
+
│ ├── callbacks/ # Your custom callback components
|
|
135
|
+
│ └── stages/ # Your custom stage components
|
|
136
|
+
├── .generator-version # Tracks the framework version in use
|
|
137
|
+
└── pnpm-workspace.yaml
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
> **All commands below must be run from the `tools/cli/` directory.**
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
cd tools/cli
|
|
146
|
+
|
|
147
|
+
# Install dependencies
|
|
148
|
+
pnpm install
|
|
149
|
+
|
|
150
|
+
# Build the CLI
|
|
151
|
+
pnpm build
|
|
152
|
+
|
|
153
|
+
# Run tests
|
|
154
|
+
pnpm test
|
|
155
|
+
|
|
156
|
+
# Watch mode (rebuild on file changes)
|
|
157
|
+
pnpm dev
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Running locally without installing globally
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
# From tools/cli/ after pnpm build:
|
|
164
|
+
node dist/main.js init my-project
|
|
165
|
+
|
|
166
|
+
# Or from anywhere using the full path:
|
|
167
|
+
node /path/to/forgerock-web-login-framework/tools/cli/dist/main.js init my-project
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Project structure
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
tools/cli/
|
|
174
|
+
├── src/
|
|
175
|
+
│ ├── main.ts # CLI entry point (ping-lf binary)
|
|
176
|
+
│ ├── errors.ts # Typed Effect errors
|
|
177
|
+
│ ├── commands/
|
|
178
|
+
│ │ ├── init.ts # ping-lf init
|
|
179
|
+
│ │ ├── generate.ts # ping-lf generate callback|stage
|
|
180
|
+
│ │ └── update.ts # ping-lf update
|
|
181
|
+
│ ├── services/
|
|
182
|
+
│ │ ├── file-system.ts # copyWithExclusions
|
|
183
|
+
│ │ ├── registry.ts # component scanning + registry generation
|
|
184
|
+
│ │ ├── release.ts # GitHub release fetch
|
|
185
|
+
│ │ └── vite-config.ts # Vite plugin injection
|
|
186
|
+
│ └── config/
|
|
187
|
+
│ ├── exclusions.ts # Files/dirs excluded from copy operations
|
|
188
|
+
│ └── version.ts # .generator-version read/write
|
|
189
|
+
├── templates/
|
|
190
|
+
│ ├── callback/ # Scaffold templates for callbacks
|
|
191
|
+
│ └── stage/ # Scaffold templates for stages
|
|
192
|
+
└── test/ # Vitest unit tests
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Tech stack
|
|
196
|
+
|
|
197
|
+
- **[Effect](https://effect.website/)** — typed async handling, composable services, declarative errors
|
|
198
|
+
- **[@effect/cli](https://github.com/Effect-TS/effect/tree/main/packages/cli)** — CLI parsing and help generation
|
|
199
|
+
- **[@effect/platform-node](https://github.com/Effect-TS/effect/tree/main/packages/platform-node)** — Node.js FileSystem, Path, HTTP client
|
|
200
|
+
- **[Vitest](https://vitest.dev/)** — unit tests
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Thin Effect runner for generating the custom component registry.
|
|
4
|
+
*
|
|
5
|
+
* Called by:
|
|
6
|
+
* - packages/login-widget/package.json "prebuild"
|
|
7
|
+
* - Root package.json "generate:registry"
|
|
8
|
+
*
|
|
9
|
+
* Requires the CLI to be compiled first:
|
|
10
|
+
* pnpm --filter @forgerock/login-framework-cli run build
|
|
11
|
+
*/
|
|
12
|
+
import { Effect } from 'effect';
|
|
13
|
+
import { NodeContext, NodeRuntime } from '@effect/platform-node';
|
|
14
|
+
const { runRegistryScript } = await import('../src/services/registry.js');
|
|
15
|
+
const projectDir = process.argv[2];
|
|
16
|
+
if (!projectDir) {
|
|
17
|
+
console.error('Usage: node generate-registry.mjs <projectDir>');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
NodeRuntime.runMain(runRegistryScript(projectDir).pipe(Effect.provide(NodeContext.layer)));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Command } from '@effect/cli';
|
|
2
|
+
import { FileSystem, Path } from '@effect/platform';
|
|
3
|
+
import { Schema } from 'effect';
|
|
4
|
+
import { ComponentAlreadyExistsError, InvalidComponentNameError } from '../errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Validates a callback component name: must be a valid name (PascalCase, alphanumeric only).
|
|
7
|
+
* Callback names are driven by AM's callback type strings (e.g. NameCallback, PasswordCallback).
|
|
8
|
+
*/
|
|
9
|
+
export declare const CallbackNameSchema: Schema.transform<Schema.filter<typeof Schema.String>, Schema.Struct<{
|
|
10
|
+
name: typeof Schema.String;
|
|
11
|
+
slug: typeof Schema.String;
|
|
12
|
+
}>>;
|
|
13
|
+
/**
|
|
14
|
+
* Validates a stage component name: accepts any string that is safe for a directory name and
|
|
15
|
+
* HTML comment embedding. Stage names in AM are arbitrary strings set on the Page Node — there
|
|
16
|
+
* is no name constraint, unlike callbacks.
|
|
17
|
+
*/
|
|
18
|
+
export declare const StageNameSchema: Schema.transform<Schema.filter<typeof Schema.String>, Schema.Struct<{
|
|
19
|
+
name: typeof Schema.String;
|
|
20
|
+
slug: typeof Schema.String;
|
|
21
|
+
}>>;
|
|
22
|
+
export declare const generateCommand: Command.Command<"generate", FileSystem.FileSystem | Path.Path | import("@effect/platform/Terminal").Terminal, import("../errors.js").GeneratorVersionError | import("../errors.js").RegistryScanError | InvalidComponentNameError | ComponentAlreadyExistsError | import("@effect/platform/Error").PlatformError | import("@effect/platform/Terminal").QuitException, {
|
|
23
|
+
readonly subcommand: import("effect/Option").Option<{
|
|
24
|
+
readonly name: string;
|
|
25
|
+
} | {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
}>;
|
|
28
|
+
}>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Args, Command, Prompt } from '@effect/cli';
|
|
2
|
+
import { FileSystem, Path } from '@effect/platform';
|
|
3
|
+
import { Console, Effect, Schema } from 'effect';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import nodePath from 'node:path';
|
|
6
|
+
import { runRegistryScript } from '../services/registry.js';
|
|
7
|
+
import { assertValidProject } from '../config/version.js';
|
|
8
|
+
import { ComponentAlreadyExistsError, InvalidComponentNameError } from '../errors.js';
|
|
9
|
+
const CUSTOM_DIR = 'experimental/custom';
|
|
10
|
+
/**
|
|
11
|
+
* Converts a PascalCase name to a kebab-case slug.
|
|
12
|
+
* Uses two passes to correctly handle acronyms.
|
|
13
|
+
* Examples: "MyCallback" → "my-callback", "JWTLogin" → "jwt-login", "MyURLCallback" → "my-url-callback"
|
|
14
|
+
*/
|
|
15
|
+
function toKebabCase(name) {
|
|
16
|
+
return name
|
|
17
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
18
|
+
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
|
|
19
|
+
.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Converts an arbitrary string to a kebab-case directory slug.
|
|
23
|
+
* Used for stage names which may contain spaces, hyphens, or mixed case.
|
|
24
|
+
* Examples: "My Login Stage" → "my-login-stage", "DefaultLogin" → "defaultlogin", "OTP Login" → "otp-login"
|
|
25
|
+
*/
|
|
26
|
+
function toSlug(name) {
|
|
27
|
+
return name
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
30
|
+
.replace(/^-+|-+$/g, '');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Validates a callback component name: must be a valid name (PascalCase, alphanumeric only).
|
|
34
|
+
* Callback names are driven by AM's callback type strings (e.g. NameCallback, PasswordCallback).
|
|
35
|
+
*/
|
|
36
|
+
export const CallbackNameSchema = Schema.String.pipe(Schema.filter((s) => /^[A-Z][A-Za-z0-9]+$/.test(s)), Schema.transform(Schema.Struct({ name: Schema.String, slug: Schema.String }), {
|
|
37
|
+
strict: true,
|
|
38
|
+
decode: (name) => ({ name, slug: toKebabCase(name) }),
|
|
39
|
+
encode: ({ name }) => name,
|
|
40
|
+
}));
|
|
41
|
+
/**
|
|
42
|
+
* Validates a stage component name: accepts any string that is safe for a directory name and
|
|
43
|
+
* HTML comment embedding. Stage names in AM are arbitrary strings set on the Page Node — there
|
|
44
|
+
* is no name constraint, unlike callbacks.
|
|
45
|
+
*/
|
|
46
|
+
export const StageNameSchema = Schema.String.pipe(Schema.filter((s) => s.length >= 2 &&
|
|
47
|
+
/[A-Za-z]/.test(s) &&
|
|
48
|
+
!s.includes('\0') &&
|
|
49
|
+
!/[\n\r]/.test(s) &&
|
|
50
|
+
!/^\.\.?[/\\]/.test(s)), Schema.transform(Schema.Struct({ name: Schema.String, slug: Schema.String }), {
|
|
51
|
+
strict: true,
|
|
52
|
+
decode: (name) => ({ name, slug: toSlug(name) }),
|
|
53
|
+
encode: ({ name }) => name,
|
|
54
|
+
}));
|
|
55
|
+
/**
|
|
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
|
+
*/
|
|
60
|
+
function getTemplatesDir() {
|
|
61
|
+
const __dirname = nodePath.dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
return nodePath.join(__dirname, '../templates');
|
|
63
|
+
}
|
|
64
|
+
function scaffoldComponent(type, name) {
|
|
65
|
+
return Effect.gen(function* () {
|
|
66
|
+
const fs = yield* FileSystem.FileSystem;
|
|
67
|
+
const p = yield* Path.Path;
|
|
68
|
+
// ── Guard: validate component name format ─────────────────────────────
|
|
69
|
+
// Callbacks must be valid name (PascalCase, alphanumeric only).
|
|
70
|
+
// Stages are arbitrary AM strings with no naming convention constraint.
|
|
71
|
+
const nameSchema = type === 'callback' ? CallbackNameSchema : StageNameSchema;
|
|
72
|
+
const { slug } = yield* Schema.decode(nameSchema)(name).pipe(Effect.mapError(() => new InvalidComponentNameError({ name })));
|
|
73
|
+
const cwd = process.cwd();
|
|
74
|
+
// ── Guard: must be run from an initialized project root ───────────────
|
|
75
|
+
yield* assertValidProject(cwd);
|
|
76
|
+
const subDir = type === 'callback' ? 'callbacks' : 'stages';
|
|
77
|
+
const componentDir = p.join(cwd, CUSTOM_DIR, subDir, slug);
|
|
78
|
+
const templatesDir = p.join(getTemplatesDir(), type);
|
|
79
|
+
// ── Guard: prevent overwriting an existing component ──────────────────
|
|
80
|
+
const exists = yield* fs.exists(componentDir);
|
|
81
|
+
if (exists) {
|
|
82
|
+
yield* Effect.fail(new ComponentAlreadyExistsError({ path: componentDir }));
|
|
83
|
+
}
|
|
84
|
+
yield* Console.log(`\nGenerating ${type} component: ${name}\n`);
|
|
85
|
+
// ── Create component directory ─────────────────────────────────────────
|
|
86
|
+
yield* fs.makeDirectory(componentDir, { recursive: true });
|
|
87
|
+
// ── Copy and process template files ───────────────────────────────────
|
|
88
|
+
// Each file name contains __COMPONENT_SLUG__; its contents contain both
|
|
89
|
+
// __COMPONENT_NAME__ (PascalCase) and __COMPONENT_SLUG__ (kebab-case).
|
|
90
|
+
const templateFiles = yield* fs.readDirectory(templatesDir);
|
|
91
|
+
const createdFiles = [];
|
|
92
|
+
for (const templateFile of templateFiles) {
|
|
93
|
+
const targetFileName = templateFile.replaceAll('__COMPONENT_SLUG__', slug);
|
|
94
|
+
const sourceContent = yield* fs.readFileString(p.join(templatesDir, templateFile));
|
|
95
|
+
const processedContent = sourceContent
|
|
96
|
+
.replaceAll('__COMPONENT_NAME__', name)
|
|
97
|
+
.replaceAll('__COMPONENT_SLUG__', slug);
|
|
98
|
+
const targetPath = p.join(componentDir, targetFileName);
|
|
99
|
+
yield* fs.writeFileString(targetPath, processedContent);
|
|
100
|
+
createdFiles.push(targetPath);
|
|
101
|
+
}
|
|
102
|
+
// ── Regenerate custom-registry.ts ────────────────────────────────────
|
|
103
|
+
yield* Console.log('Regenerating custom component registry...');
|
|
104
|
+
yield* runRegistryScript(cwd);
|
|
105
|
+
// ── Print summary ──────────────────────────────────────────────────────
|
|
106
|
+
yield* Console.log(`Done. ${type} component scaffolded successfully.\n\n` +
|
|
107
|
+
`Files created:\n` +
|
|
108
|
+
createdFiles.map((f) => ` ${f}`).join('\n') +
|
|
109
|
+
`\n\nNext: open ${p.join(componentDir, `${slug}.svelte`)} and implement your component.\n`);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// ── Subcommands ──────────────────────────────────────────────────────────────
|
|
113
|
+
const generateCallbackCommand = Command.make('callback', {
|
|
114
|
+
name: Args.text({ name: 'Name' }).pipe(Args.withDescription('PascalCase component name (e.g. MyCallback). Must match the callback type string your AM node sends.')),
|
|
115
|
+
}, ({ name }) => scaffoldComponent('callback', name)).pipe(Command.withDescription('Scaffold a new custom callback component under experimental/custom/callbacks/.'));
|
|
116
|
+
const generateStageCommand = Command.make('stage', {
|
|
117
|
+
name: Args.text({ name: 'Name' }).pipe(Args.withDescription('Stage name as configured on your AM journey Page Node (e.g. "DefaultLogin" or "My Login Stage"). Any non-empty string is accepted — stage names in AM are arbitrary and not constrained to PascalCase.')),
|
|
118
|
+
}, ({ name }) => scaffoldComponent('stage', name)).pipe(Command.withDescription('Scaffold a new custom stage component under experimental/custom/stages/.'));
|
|
119
|
+
export const generateCommand = Command.make('generate', {}, () => Effect.gen(function* () {
|
|
120
|
+
const type = yield* Prompt.select({
|
|
121
|
+
message: 'Select component type',
|
|
122
|
+
choices: [
|
|
123
|
+
{
|
|
124
|
+
title: 'callback',
|
|
125
|
+
value: 'callback',
|
|
126
|
+
description: 'Custom AM callback renderer',
|
|
127
|
+
},
|
|
128
|
+
{ title: 'stage', value: 'stage', description: 'Custom journey stage layout' },
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
const name = yield* Prompt.text({
|
|
132
|
+
message: type === 'callback'
|
|
133
|
+
? 'Callback name (PascalCase name, e.g. MyCallback)'
|
|
134
|
+
: 'Stage name (as set on your AM Page Node, e.g. DefaultLogin or My Login Stage)',
|
|
135
|
+
});
|
|
136
|
+
yield* scaffoldComponent(type, name);
|
|
137
|
+
})).pipe(Command.withDescription('Scaffold a new custom callback or stage component from a template.'), Command.withSubcommands([generateCallbackCommand, generateStageCommand]));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Command } from '@effect/cli';
|
|
2
|
+
import { FileSystem, Path } from '@effect/platform';
|
|
3
|
+
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 | import("../errors.js").RegistryScanError | DirectoryConflictError | DirectoryNotEmptyError | import("@effect/platform/Error").PlatformError, {
|
|
5
|
+
readonly directory: string;
|
|
6
|
+
readonly local: import("effect/Option").Option<string>;
|
|
7
|
+
readonly version: import("effect/Option").Option<string>;
|
|
8
|
+
}>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Args, Command, Options } from '@effect/cli';
|
|
2
|
+
import { FileSystem, Path } from '@effect/platform';
|
|
3
|
+
import { Console, Effect } from 'effect';
|
|
4
|
+
import { DirectoryConflictError, DirectoryNotEmptyError } from '../errors.js';
|
|
5
|
+
import { writeVersion } from '../config/version.js';
|
|
6
|
+
import { copyWithExclusions, expandTilde, isFrameworkDirectory } from '../services/file-system.js';
|
|
7
|
+
import { runRegistryScript } from '../services/registry.js';
|
|
8
|
+
import { resolveSource } from './source.js';
|
|
9
|
+
/**
|
|
10
|
+
* Minimal pnpm-workspace.yaml written to the customer project.
|
|
11
|
+
* Intentionally omits `tools/` — that workspace entry exists only in the
|
|
12
|
+
* upstream monorepo and is not needed (or valid) in a customer project.
|
|
13
|
+
*/
|
|
14
|
+
const PNPM_WORKSPACE = `packages:
|
|
15
|
+
- 'packages/*'
|
|
16
|
+
- 'apps/*'
|
|
17
|
+
- 'e2e'
|
|
18
|
+
`;
|
|
19
|
+
const nextStepsMessage = (dir) => `
|
|
20
|
+
Done. Project initialized successfully.
|
|
21
|
+
|
|
22
|
+
Next steps:
|
|
23
|
+
cd ${dir}
|
|
24
|
+
pnpm install
|
|
25
|
+
cp .env.example .env # add your ForgeRock AM connection details
|
|
26
|
+
pnpm dev
|
|
27
|
+
|
|
28
|
+
To scaffold your first custom component:
|
|
29
|
+
ping-lf generate callback MyCallback
|
|
30
|
+
ping-lf generate stage MyStage
|
|
31
|
+
|
|
32
|
+
Read the authoring guide: experimental/custom/README.md
|
|
33
|
+
`;
|
|
34
|
+
export const initCommand = Command.make('init', {
|
|
35
|
+
directory: Args.text({ name: 'directory' }).pipe(Args.withDescription('Directory to initialize the project in. Use "./" for the current directory.')),
|
|
36
|
+
local: Options.optional(Options.text('local').pipe(Options.withDescription('Path to a local framework directory. If omitted, the main branch is downloaded from GitHub.'))),
|
|
37
|
+
version: Options.optional(Options.text('version').pipe(Options.withDescription('Framework version tag to download (e.g. v1.2.0). If omitted, the main branch is used.'))),
|
|
38
|
+
}, ({ directory, local, version }) => Effect.gen(function* () {
|
|
39
|
+
const fs = yield* FileSystem.FileSystem;
|
|
40
|
+
const path = yield* Path.Path;
|
|
41
|
+
const resolvedDir = path.resolve(expandTilde(directory));
|
|
42
|
+
yield* Console.log(`\nInitializing Ping Login Widget / Login App project in: ${resolvedDir}\n`);
|
|
43
|
+
// ── 0. Validate target directory ──────────────────────────────────────
|
|
44
|
+
const dirExists = yield* fs.exists(resolvedDir);
|
|
45
|
+
if (dirExists) {
|
|
46
|
+
const isFramework = yield* isFrameworkDirectory(resolvedDir);
|
|
47
|
+
if (isFramework) {
|
|
48
|
+
yield* Effect.fail(new DirectoryConflictError({ path: resolvedDir }));
|
|
49
|
+
}
|
|
50
|
+
const entries = yield* fs
|
|
51
|
+
.readDirectory(resolvedDir)
|
|
52
|
+
.pipe(Effect.orElseSucceed(() => []));
|
|
53
|
+
if (entries.length > 0) {
|
|
54
|
+
yield* Effect.fail(new DirectoryNotEmptyError({ path: resolvedDir }));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// ── 1. Resolve source + copy framework (scoped: temp dir auto-cleaned) ──
|
|
58
|
+
// Effect.scoped closes the Scope that release.fetch opens, triggering
|
|
59
|
+
// automatic removal of the .framework-tmp directory after copying.
|
|
60
|
+
const resolvedVersion = yield* Effect.scoped(Effect.gen(function* () {
|
|
61
|
+
const { sourceDir, resolvedVersion } = yield* resolveSource(local, version, resolvedDir);
|
|
62
|
+
// ── 2. Create target directory + copy framework (with exclusions) ──
|
|
63
|
+
yield* Console.log('Copying framework files...');
|
|
64
|
+
yield* fs.makeDirectory(resolvedDir, { recursive: true });
|
|
65
|
+
yield* copyWithExclusions(sourceDir, resolvedDir);
|
|
66
|
+
return resolvedVersion;
|
|
67
|
+
}));
|
|
68
|
+
// ── 3. Write pnpm-workspace.yaml (omits tools/ present in upstream) ────
|
|
69
|
+
yield* fs.writeFileString(path.join(resolvedDir, 'pnpm-workspace.yaml'), PNPM_WORKSPACE);
|
|
70
|
+
// ── 4. Scaffold experimental/custom/ ──────────────────────────────────
|
|
71
|
+
// copyWithExclusions already copies experimental/custom/demo/,
|
|
72
|
+
// login-framework.ts, README.md, and tsconfig.json from the source.
|
|
73
|
+
// Only callbacks/ and stages/ are protected (they hold user components).
|
|
74
|
+
// Create them as empty dirs with .gitkeep — components come via `ping-lf generate`.
|
|
75
|
+
yield* Console.log('Scaffolding custom component directories...');
|
|
76
|
+
yield* Effect.forEach(['callbacks', 'stages'], (dir) => Effect.gen(function* () {
|
|
77
|
+
const customDir = path.join(resolvedDir, 'experimental', 'custom', dir);
|
|
78
|
+
yield* fs.makeDirectory(customDir, { recursive: true });
|
|
79
|
+
yield* fs.writeFileString(path.join(customDir, '.gitkeep'), '');
|
|
80
|
+
}), { concurrency: 'unbounded', discard: true });
|
|
81
|
+
// ── 5. Generate custom-registry.ts ────────────────────────────────────
|
|
82
|
+
// Run the canonical registry script so the file is immediately present
|
|
83
|
+
// with the correct CustomRegistryEntry format. The empty callbacks/ and
|
|
84
|
+
// stages/ dirs produce empty registries — no custom components yet.
|
|
85
|
+
yield* Console.log('Generating custom component registry...');
|
|
86
|
+
yield* runRegistryScript(resolvedDir);
|
|
87
|
+
// ── 6. Write .generator-version ────────────────────────────────────────
|
|
88
|
+
yield* writeVersion(resolvedDir, {
|
|
89
|
+
version: resolvedVersion,
|
|
90
|
+
commitHash: '',
|
|
91
|
+
generatedAt: new Date().toISOString(),
|
|
92
|
+
});
|
|
93
|
+
// ── 7. Print next steps ───────────────────────────────────────────────
|
|
94
|
+
yield* Console.log(nextStepsMessage(directory));
|
|
95
|
+
})).pipe(Command.withDescription('Bootstrap a new Ping Login Widget or Login App project from a GitHub release or a local framework path.'));
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { Command } from '@effect/cli';
|
|
2
|
+
import { Release } from '../services/release.js';
|
|
3
|
+
export declare const releasesCommand: Command.Command<"releases", Release, import("../errors.js").ReleaseNetworkError | import("../errors.js").ReleaseParseError | import("../errors.js").ReleaseNotFoundError, {}>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Command } from '@effect/cli';
|
|
2
|
+
import { Console, Effect } from 'effect';
|
|
3
|
+
import { Release } from '../services/release.js';
|
|
4
|
+
export const releasesCommand = Command.make('releases', {}, () => Effect.gen(function* () {
|
|
5
|
+
const release = yield* Release;
|
|
6
|
+
const releases = yield* release.listReleases();
|
|
7
|
+
yield* Console.log('\nAvailable Login Framework releases:\n');
|
|
8
|
+
for (const { tag, publishedAt } of releases) {
|
|
9
|
+
yield* Console.log(` ${tag.padEnd(10)} ${publishedAt}`);
|
|
10
|
+
}
|
|
11
|
+
yield* Console.log('');
|
|
12
|
+
})).pipe(Command.withDescription('List available Login Framework releases from GitHub.'));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Path } from '@effect/platform';
|
|
2
|
+
import { Effect, Option } from 'effect';
|
|
3
|
+
import { Release } from '../services/release.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the framework source directory and version from a local path or a
|
|
6
|
+
* GitHub release download. Shared by `init` and `update` commands.
|
|
7
|
+
*
|
|
8
|
+
* @param local - Optional local framework directory path.
|
|
9
|
+
* @param version - Optional explicit release version tag.
|
|
10
|
+
* @param targetDir - Directory that will receive the downloaded release temp
|
|
11
|
+
* folder (`.framework-tmp`).
|
|
12
|
+
*/
|
|
13
|
+
export declare const resolveSource: (local: Option.Option<string>, version: Option.Option<string>, targetDir: string) => Effect.Effect<{
|
|
14
|
+
sourceDir: string;
|
|
15
|
+
resolvedVersion: "main";
|
|
16
|
+
} | {
|
|
17
|
+
sourceDir: string;
|
|
18
|
+
resolvedVersion: string;
|
|
19
|
+
} | {
|
|
20
|
+
sourceDir: string;
|
|
21
|
+
resolvedVersion: "local";
|
|
22
|
+
}, import("../errors.js").InvalidVersionError | import("../errors.js").ReleaseNetworkError | import("../errors.js").ReleaseParseError | import("../errors.js").ReleaseFsError, import("@effect/platform/FileSystem").FileSystem | Path.Path | Release | import("effect/Scope").Scope>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Path } from '@effect/platform';
|
|
2
|
+
import { Console, Effect, Option, pipe } from 'effect';
|
|
3
|
+
import { expandTilde } from '../services/file-system.js';
|
|
4
|
+
import { Release } from '../services/release.js';
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the framework source directory and version from a local path or a
|
|
7
|
+
* GitHub release download. Shared by `init` and `update` commands.
|
|
8
|
+
*
|
|
9
|
+
* @param local - Optional local framework directory path.
|
|
10
|
+
* @param version - Optional explicit release version tag.
|
|
11
|
+
* @param targetDir - Directory that will receive the downloaded release temp
|
|
12
|
+
* folder (`.framework-tmp`).
|
|
13
|
+
*/
|
|
14
|
+
export const resolveSource = (local, version, targetDir) => Effect.gen(function* () {
|
|
15
|
+
const path = yield* Path.Path;
|
|
16
|
+
const release = yield* Release;
|
|
17
|
+
return yield* Option.match(local, {
|
|
18
|
+
onSome: (localPath) => pipe(Effect.sync(() => path.resolve(expandTilde(localPath))), Effect.tap((dir) => Console.log(`Using local source: ${dir}`)), Effect.map((dir) => ({ sourceDir: dir, resolvedVersion: 'local' }))),
|
|
19
|
+
onNone: () => pipe(Option.match(version, {
|
|
20
|
+
onSome: (v) => pipe(Effect.tap(Effect.succeed(v), (v) => Console.log(`Fetching framework version: ${v}...`)), Effect.flatMap((v) => pipe(release.fetch(v, targetDir), Effect.map((sourceDir) => ({ sourceDir, resolvedVersion: v }))))),
|
|
21
|
+
onNone: () => pipe(Console.log('Fetching framework from main branch...'), Effect.flatMap(() => release.fetchBranch('main', targetDir)), Effect.map((sourceDir) => ({ sourceDir, resolvedVersion: 'main' }))),
|
|
22
|
+
})),
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
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("../errors.js").RegistryScanError | import("@effect/platform/Error").PlatformError, {
|
|
3
|
+
readonly local: import("effect/Option").Option<string>;
|
|
4
|
+
readonly version: import("effect/Option").Option<string>;
|
|
5
|
+
}>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Command, Options } from '@effect/cli';
|
|
2
|
+
import { Console, Effect } from 'effect';
|
|
3
|
+
import { writeVersion, assertValidProject } from '../config/version.js';
|
|
4
|
+
import { copyWithExclusions } from '../services/file-system.js';
|
|
5
|
+
import { runRegistryScript } from '../services/registry.js';
|
|
6
|
+
import { resolveSource } from './source.js';
|
|
7
|
+
export const updateCommand = Command.make('update', {
|
|
8
|
+
local: Options.optional(Options.text('local').pipe(Options.withDescription('Path to a local framework directory. If omitted, the main branch is downloaded from GitHub.'))),
|
|
9
|
+
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.'))),
|
|
10
|
+
}, ({ local, version }) => Effect.gen(function* () {
|
|
11
|
+
const cwd = process.cwd();
|
|
12
|
+
// ── 1. Verify this is an initialized project ──────────────────────────
|
|
13
|
+
const currentVersion = yield* assertValidProject(cwd);
|
|
14
|
+
yield* Console.log(`\nUpdating project from version: ${currentVersion.version}\n`);
|
|
15
|
+
// ── 2. Resolve source + copy framework (scoped: temp dir auto-cleaned) ──
|
|
16
|
+
// Effect.scoped closes the Scope that release.fetch opens, triggering
|
|
17
|
+
// automatic removal of the .framework-tmp directory after copying.
|
|
18
|
+
const resolvedVersion = yield* Effect.scoped(Effect.gen(function* () {
|
|
19
|
+
const { sourceDir, resolvedVersion } = yield* resolveSource(local, version, cwd);
|
|
20
|
+
yield* Console.log('Copying updated framework files...');
|
|
21
|
+
yield* copyWithExclusions(sourceDir, cwd);
|
|
22
|
+
return resolvedVersion;
|
|
23
|
+
}));
|
|
24
|
+
// ── 3. Regenerate custom-registry.ts ──────────────────────────────────
|
|
25
|
+
yield* Console.log('Regenerating custom component registry...');
|
|
26
|
+
yield* runRegistryScript(cwd);
|
|
27
|
+
// ── 4. Update .generator-version ──────────────────────────────────────
|
|
28
|
+
yield* writeVersion(cwd, {
|
|
29
|
+
version: resolvedVersion,
|
|
30
|
+
commitHash: '',
|
|
31
|
+
generatedAt: new Date().toISOString(),
|
|
32
|
+
});
|
|
33
|
+
yield* Console.log(`\nDone. Updated from ${currentVersion.version} to ${resolvedVersion}.\n` +
|
|
34
|
+
'Custom component registry regenerated.\n' +
|
|
35
|
+
'Run "pnpm install" if package dependencies changed.\n');
|
|
36
|
+
})).pipe(Command.withDescription('Fetch the latest (or a specified) framework version and overwrite core files while preserving experimental/custom/.'));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isExcluded(relativePath: string): boolean;
|