@junejuly-lockstep/reconciler 0.1.0
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 +182 -0
- package/dist/boot.d.ts +51 -0
- package/dist/boot.d.ts.map +1 -0
- package/dist/boot.js +162 -0
- package/dist/boot.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +180 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +140 -0
- package/dist/config.js.map +1 -0
- package/dist/contract.d.ts +61 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +154 -0
- package/dist/contract.js.map +1 -0
- package/dist/determinism.d.ts +23 -0
- package/dist/determinism.d.ts.map +1 -0
- package/dist/determinism.js +102 -0
- package/dist/determinism.js.map +1 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/exitCodes.d.ts +33 -0
- package/dist/exitCodes.d.ts.map +1 -0
- package/dist/exitCodes.js +49 -0
- package/dist/exitCodes.js.map +1 -0
- package/dist/extract.d.ts +30 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +376 -0
- package/dist/extract.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +17 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +55 -0
- package/dist/init.js.map +1 -0
- package/dist/journeys.d.ts +34 -0
- package/dist/journeys.d.ts.map +1 -0
- package/dist/journeys.js +276 -0
- package/dist/journeys.js.map +1 -0
- package/dist/paths.d.ts +26 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +80 -0
- package/dist/paths.js.map +1 -0
- package/dist/reconcile.d.ts +63 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +316 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/report.d.ts +33 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +97 -0
- package/dist/report.js.map +1 -0
- package/dist/sync.d.ts +46 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +110 -0
- package/dist/sync.js.map +1 -0
- package/dist/upload.d.ts +33 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +86 -0
- package/dist/upload.js.map +1 -0
- package/dist/util.d.ts +24 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +97 -0
- package/dist/util.js.map +1 -0
- package/dist/visual.d.ts +49 -0
- package/dist/visual.d.ts.map +1 -0
- package/dist/visual.js +145 -0
- package/dist/visual.js.map +1 -0
- package/dist/walk.d.ts +147 -0
- package/dist/walk.d.ts.map +1 -0
- package/dist/walk.js +433 -0
- package/dist/walk.js.map +1 -0
- package/package.json +78 -0
- package/templates/example.journey.ts +90 -0
- package/templates/lockstep-reconcile.yml +219 -0
- package/templates/lockstep.config.example.ts +46 -0
- package/templates/msw-handlers.example.ts +113 -0
- package/templates/scenarios.example.ts +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JuneJuly Health
|
|
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,182 @@
|
|
|
1
|
+
# @junejuly-lockstep/reconciler
|
|
2
|
+
|
|
3
|
+
The CI side of Lockstep. `lockstep reconcile` runs inside the client repo's GitHub Actions, never on
|
|
4
|
+
platform infrastructure, and never against a real backend. It extracts the navigation graph from
|
|
5
|
+
source, snapshots the GraphQL contract, walks every declared journey in Chromium against seeded
|
|
6
|
+
mocks, diffs screenshots against approved baselines, uploads the evidence, and posts one
|
|
7
|
+
`RunPayload` to the platform. The platform computes verdicts; this package only reports facts.
|
|
8
|
+
|
|
9
|
+
`lockstep sync-models` is the lightweight sibling that runs on every push to the default branch
|
|
10
|
+
and mirrors the parsed journey models to the platform (see "Model sync" below).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add -D @junejuly-lockstep/reconciler playwright
|
|
16
|
+
pnpm exec playwright install --with-deps chromium
|
|
17
|
+
npx lockstep init # writes the example config, journey, scenarios, MSW handlers, workflow
|
|
18
|
+
npx lockstep validate # checks the contract below, no walk
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Node 20.18 or newer. `xstate` v5 and `msw` v2 are expected in the client app (the example journey
|
|
22
|
+
and handlers import them).
|
|
23
|
+
|
|
24
|
+
## Contract checklist
|
|
25
|
+
|
|
26
|
+
The reconciler fails fast with exit code 1 and a named violation when any item is missing.
|
|
27
|
+
`lockstep validate` prints the same list with a pass/fail per item.
|
|
28
|
+
|
|
29
|
+
| Item | What the reconciler checks | Violation name |
|
|
30
|
+
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
31
|
+
| `lockstep.config.ts` at the repo root | parses with the `LockstepConfig` schema: `projectKey`, `apiBaseUrl`, `journeysGlob`, `scenariosPath`, `seams` (6 to 8), `visualDiffThreshold`, `bootCommand`, `port`, optional `appApiBase`, `mockApiPattern`, `schema` | `lockstep.config.ts`, `lockstep.config.ts:<field>` |
|
|
32
|
+
| One `*.journey.ts` per journey matching the glob | exports `machine` (XState v5 `createMachine` or plain config) and `meta` (narrative, per-state `route`/`testId`/`intent`/`rules`, `bindings`); every state has meta, every target resolves | `journeys`, `journey:<file>` |
|
|
33
|
+
| Scenarios file | exports an array of `{ name, seed, overrides }` (or a record keyed by name); names unique | `scenarios` |
|
|
34
|
+
| Seeded MSW handlers | not inspected statically; the walk refuses to run unless `appApiBase` matches `mockApiPattern` | `appApiBase`, `mockApiPattern` |
|
|
35
|
+
| `data-testid` on every state root and trigger | asserted during the walk; a missing id fails the step | (walk result, not a violation) |
|
|
36
|
+
| `.github/workflows/lockstep-reconcile.yml` committed | file exists | `workflow` |
|
|
37
|
+
| `LOCKSTEP_API_KEY` secret | env var set, `lsk_` prefix | `apiKey` |
|
|
38
|
+
| Lockstep GitHub App installed | not checked here; the platform's webhook needs it | |
|
|
39
|
+
|
|
40
|
+
Journey keys come from the file name (`book-a-visit.journey.ts` -> `book-a-visit`); export `key`
|
|
41
|
+
and `name` to override. Guards and actions in the machine are kept as string names; their
|
|
42
|
+
implementations stay in the app.
|
|
43
|
+
|
|
44
|
+
## CLI
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
lockstep reconcile [options]
|
|
48
|
+
--ref <sha> commit under test (default GITHUB_SHA, then git HEAD)
|
|
49
|
+
--kind deploy|pr default deploy
|
|
50
|
+
--pr <n> PR number (required for --kind pr unless GITHUB_REF is refs/pull/<n>/...)
|
|
51
|
+
--env staging|prod|preview default staging
|
|
52
|
+
--deploy-label <label> e.g. d14
|
|
53
|
+
--config <path> default lockstep.config.ts
|
|
54
|
+
--out <dir> default .lockstep (gitignored local output)
|
|
55
|
+
--dry-run full local pipeline, no API calls, no upload, no post
|
|
56
|
+
--fail-on-verdicts auto|always|never
|
|
57
|
+
auto = report-only for pr, fail for deploy
|
|
58
|
+
--determinism-check walk twice; exit 2 if any screenshot differs
|
|
59
|
+
--declared-seams <a,b> seams the author declares a backend change touches
|
|
60
|
+
--base <ref> git ref whose SDL the change list diffs against (default HEAD~1)
|
|
61
|
+
--api-base-url <url> post to this platform origin instead of apiBaseUrl in the config
|
|
62
|
+
(also LOCKSTEP_API_BASE_URL); for local rehearsals, never set in CI
|
|
63
|
+
|
|
64
|
+
lockstep sync-models [options]
|
|
65
|
+
--ref <sha> commit being synced (default GITHUB_SHA, then git HEAD)
|
|
66
|
+
--branch <name> branch pushed to (default GITHUB_REF_NAME, then the checked-out branch)
|
|
67
|
+
--pushed-at <iso> commit time (default: committer date from git, then now)
|
|
68
|
+
--config <path> default lockstep.config.ts
|
|
69
|
+
--out <dir> default .lockstep
|
|
70
|
+
--dry-run load, validate and write the payload; no API call
|
|
71
|
+
|
|
72
|
+
lockstep validate [--config <path>] [--offline]
|
|
73
|
+
lockstep init [--force] [--dir <path>]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Model sync (`lockstep sync-models`)
|
|
77
|
+
|
|
78
|
+
The platform never executes client code, and journey files are TypeScript, so the API cannot read
|
|
79
|
+
a model change out of a GitHub webhook. Instead the shipped workflow runs `lockstep sync-models` on
|
|
80
|
+
every push to the default branch: it loads `lockstep.config.ts`, the journey files and the scenarios
|
|
81
|
+
with the same loaders `reconcile` uses (same ordering, same `validateJourney` checks), builds a
|
|
82
|
+
`ModelSyncPayload` and posts it to `POST /api/ingest/models`. The platform upserts the journeys,
|
|
83
|
+
stores one model version per journey for the commit (idempotent), stores the scenarios, archives
|
|
84
|
+
journeys the default branch no longer declares, and the canvas picks the change up on its next
|
|
85
|
+
10 second poll. The GitHub `push` webhook only marks the commit as "model sync pending" until the
|
|
86
|
+
payload arrives, which is what the pending chip in the canvas header shows.
|
|
87
|
+
|
|
88
|
+
Exit codes: 0 synced, 1 contract violation (including a 422 from the platform, which lists the
|
|
89
|
+
rejected journeys), 2 infrastructure (network, platform down). Local output:
|
|
90
|
+
`.lockstep/model-sync-payload.json` and `.lockstep/model-sync-response.json`.
|
|
91
|
+
|
|
92
|
+
Pipeline order inside `reconcile`:
|
|
93
|
+
|
|
94
|
+
1. Load and validate the config, verify the contract, refuse non-mock `appApiBase`.
|
|
95
|
+
2. Load journeys (jiti, TypeScript at runtime) and scenarios.
|
|
96
|
+
3. Extract with ts-morph: route manifest (app router and pages router), every `<Link href>`,
|
|
97
|
+
`router.push`, `router.replace`, `redirect()` with enclosing handler, trigger element text and
|
|
98
|
+
`data-testid`, lifted guard predicate; dynamic targets are `unresolved`.
|
|
99
|
+
4. Contract snapshot: sha256 of the lexicographically sorted, re-printed SDL; per-operation response
|
|
100
|
+
shape hashes from the graphql-codegen output; lockfile hash. A human-readable change list against
|
|
101
|
+
`--base` is written to `.lockstep/contract-changes.md`.
|
|
102
|
+
5. Fetch the baseline manifest (`GET /api/ingest/baselines?project=KEY`) and download baselines.
|
|
103
|
+
6. Walk: boot the app once per scenario with `LOCKSTEP_SCENARIO` and `LOCKSTEP_SEED`, enumerate one
|
|
104
|
+
shortest path per state with `@xstate/graph`, execute each path in Chromium, full-page PNG per
|
|
105
|
+
step, GraphQL operation log per step, Playwright trace per path. One retry per step; a second
|
|
106
|
+
failure records `fail` and skips the rest of that path. The boot only counts once the port has
|
|
107
|
+
stayed open for 750 ms with the process alive (`next start` listens before it checks for a
|
|
108
|
+
build), and an app that dies mid-walk is an infrastructure failure (exit 2), never a row of
|
|
109
|
+
failed transitions.
|
|
110
|
+
7. Visual diff with pixelmatch (per-pixel threshold 0.1); score = differing pixels / total pixels;
|
|
111
|
+
size mismatch scores 1.
|
|
112
|
+
8. Upload PNGs, diffs and traces with presigned PUTs in batches of 50 under `runs/{runId}/`.
|
|
113
|
+
9. Assemble and validate the `RunPayload`, post it to `POST /api/ingest/runs`, print a summary.
|
|
114
|
+
|
|
115
|
+
Local output in `.lockstep/`: `extracted-graph.json`, `contract-snapshot.json`,
|
|
116
|
+
`schema.canonical.graphql`, `contract-changes.md`, `walk.json`, `run-payload.json`,
|
|
117
|
+
`ingest-response.json`, `determinism.txt`, and `artifacts/` mirroring the uploaded keys. Every one
|
|
118
|
+
of these describes the current run only: stale copies from a previous run are deleted first.
|
|
119
|
+
|
|
120
|
+
To run the whole pipeline against a local platform with no AWS, see
|
|
121
|
+
[docs/M4-acceptance.md](../../docs/M4-acceptance.md) (`scripts/m4-rehearsal.ps1` / `.sh`).
|
|
122
|
+
|
|
123
|
+
## Exit codes
|
|
124
|
+
|
|
125
|
+
| Code | Meaning | Workflow behaviour |
|
|
126
|
+
| ---- | ------------------------------------------------------------------------------ | ------------------------------------------------------------ |
|
|
127
|
+
| 0 | Run posted, nothing needs judgment | green |
|
|
128
|
+
| 1 | Contract violation; nothing walked or posted | red, fix the repo |
|
|
129
|
+
| 2 | Infrastructure failure: boot, browser, upload, platform API, determinism check | red, retry or investigate |
|
|
130
|
+
| 3 | Run posted and verdicts need judgment | `auto`: PR runs return 0 (report-only), deploy runs return 3 |
|
|
131
|
+
|
|
132
|
+
The shipped workflow passes `--fail-on-verdicts never` on `pull_request` and `always` on
|
|
133
|
+
`deployment_status`, so a red deploy is visible in GitHub as well as on the board. Its
|
|
134
|
+
`sync-models` job runs on `push` to the default branch only (no browser, no app build). It also
|
|
135
|
+
accepts `workflow_dispatch` (Actions -> Lockstep reconcile -> Run workflow) with inputs `kind`
|
|
136
|
+
(deploy|pr), `env` (staging|prod|preview), `deploy_label` and `pr`, running the checked-out commit
|
|
137
|
+
with `--fail-on-verdicts auto`: the manual deploy-run path for repos whose hosting never emits
|
|
138
|
+
`deployment_status`, and a way to re-run a deploy run by hand.
|
|
139
|
+
|
|
140
|
+
## Determinism rules
|
|
141
|
+
|
|
142
|
+
Same commit plus same seed must produce byte-identical screenshots. These are enforced by the
|
|
143
|
+
walker (`src/walk.ts`) and listed by `lockstep validate`:
|
|
144
|
+
|
|
145
|
+
| Rule | How |
|
|
146
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
147
|
+
| Frozen clock | `page.clock.install({ time: FROZEN_EPOCH_MS })` then `setFixedTime`; `Date.now()` is 2026-01-15T09:30:00Z on every page |
|
|
148
|
+
| Fixed viewport | 1280x800, `deviceScaleFactor: 1`, desktop, no touch |
|
|
149
|
+
| No motion | `reducedMotion: 'reduce'`; CSS injected before first paint disables animations, transitions, caret and smooth scroll; screenshots use `animations: 'disabled'` |
|
|
150
|
+
| Fonts awaited | `document.fonts.ready` plus two animation frames before each shot; Chromium runs with font hinting and subpixel positioning off, sRGB colour profile |
|
|
151
|
+
| Full tile raster | `--disable-partial-raster`: a tile whose content changed after first paint is rastered whole, never only inside the damage rect (whose edges differ by a unit of anti-aliasing from walk to walk) |
|
|
152
|
+
| Settled after the root appears | once the state root is visible the walker settles again (network idle, fonts, four frames), since an app that fetches through a mock worker renders its root after the first settle; the pointer is parked at (0,0) after every click |
|
|
153
|
+
| Fixed locale and zone | `locale: 'en-US'`, `timezoneId: 'UTC'`, `TZ=UTC` in the app process, light colour scheme |
|
|
154
|
+
| Seeded mocks only | `LOCKSTEP_SCENARIO`/`LOCKSTEP_SEED` in the app environment and `?lockstep_scenario=&lockstep_seed=` on the first navigation; `appApiBase` must match `mockApiPattern` |
|
|
155
|
+
| Deterministic ordering | scenarios by name, journeys by key, paths by target state then event sequence, files by path |
|
|
156
|
+
| Fresh context per path | new browser context for every path: no storage, cookies or cache carry over |
|
|
157
|
+
| CSS-pixel full-page shots | `fullPage: true`, `scale: 'css'`, caret hidden, scrollbars hidden |
|
|
158
|
+
|
|
159
|
+
What the client app must do in return: derive every mock value from the scenario seed (see
|
|
160
|
+
`templates/msw-handlers.example.ts`, which uses a tiny mulberry32 PRNG and no faker), never read
|
|
161
|
+
`Date.now()` outside the page (server components run outside the frozen clock), give images fixed
|
|
162
|
+
dimensions, and avoid rendering random ids into the DOM. Run `lockstep reconcile --dry-run
|
|
163
|
+
--determinism-check` locally to prove it before the first deploy.
|
|
164
|
+
|
|
165
|
+
## Programmatic use
|
|
166
|
+
|
|
167
|
+
Every stage is exported and independently testable:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import {
|
|
171
|
+
loadAndCheck,
|
|
172
|
+
loadJourneys,
|
|
173
|
+
extractGraph,
|
|
174
|
+
snapshotContract,
|
|
175
|
+
walk,
|
|
176
|
+
compareWalk,
|
|
177
|
+
assembleRunPayload,
|
|
178
|
+
} from '@junejuly-lockstep/reconciler';
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`walk()` accepts `deps: { launch, boot }` so it can run against a fake browser in unit tests; the
|
|
182
|
+
real Playwright import is lazy and only happens when a walk actually runs.
|
package/dist/boot.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type ChildProcess } from 'node:child_process';
|
|
2
|
+
export interface BootOptions {
|
|
3
|
+
command: string;
|
|
4
|
+
port: number;
|
|
5
|
+
cwd: string;
|
|
6
|
+
env?: Record<string, string>;
|
|
7
|
+
/** How long to wait for the port to accept connections. Default 120s. */
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
/** How long the port must stay open with the process alive. Default BOOT_SETTLE_MS. */
|
|
10
|
+
settleMs?: number;
|
|
11
|
+
host?: string;
|
|
12
|
+
log?: (line: string) => void;
|
|
13
|
+
}
|
|
14
|
+
export interface BootedApp {
|
|
15
|
+
pid: number | null;
|
|
16
|
+
/** Kill the whole process tree; resolves once the process has exited. */
|
|
17
|
+
stop(): Promise<void>;
|
|
18
|
+
/** Last lines of stdout/stderr, for diagnostics. */
|
|
19
|
+
output(): string[];
|
|
20
|
+
/** False once the app process has exited (or failed to spawn). */
|
|
21
|
+
alive(): boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* How long the port must stay open, with the process still alive, before the boot counts.
|
|
25
|
+
* `next start` (and other servers) open the listener before loading the build, so a port that
|
|
26
|
+
* answers once is not proof of an app that will answer the walk.
|
|
27
|
+
*/
|
|
28
|
+
export declare const BOOT_SETTLE_MS = 750;
|
|
29
|
+
/** How long a port left behind by the previous app process may take to close before boot refuses it. */
|
|
30
|
+
export declare const PORT_RELEASE_GRACE_MS = 5000;
|
|
31
|
+
export declare function isPortOpen(port: number, host?: string, timeoutMs?: number): Promise<boolean>;
|
|
32
|
+
export declare function waitForPort(port: number, opts?: {
|
|
33
|
+
host?: string;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
intervalMs?: number;
|
|
36
|
+
isAlive?: () => boolean;
|
|
37
|
+
}): Promise<void>;
|
|
38
|
+
/** Resolve once nothing accepts connections on the port any more (or the deadline passes). */
|
|
39
|
+
export declare function waitForPortClosed(port: number, opts?: {
|
|
40
|
+
host?: string;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
intervalMs?: number;
|
|
43
|
+
}): Promise<boolean>;
|
|
44
|
+
/** Kill a process and all of its children. */
|
|
45
|
+
export declare function killTree(child: ChildProcess): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Boot the app under test with `bootCommand` and wait for `port`. The port must be free
|
|
48
|
+
* beforehand: a stray server would walk with the wrong scenario seed.
|
|
49
|
+
*/
|
|
50
|
+
export declare function bootApp(opts: BootOptions): Promise<BootedApp>;
|
|
51
|
+
//# sourceMappingURL=boot.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"boot.d.ts","sourceRoot":"","sources":["../src/boot.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAK9D,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uFAAuF;IACvF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,yEAAyE;IACzE,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,oDAAoD;IACpD,MAAM,IAAI,MAAM,EAAE,CAAC;IACnB,kEAAkE;IAClE,KAAK,IAAI,OAAO,CAAC;CAClB;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,MAAM,CAAC;AAClC,wGAAwG;AACxG,eAAO,MAAM,qBAAqB,OAAQ,CAAC;AAE3C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,SAAc,EAAE,SAAS,SAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAa/F;AAED,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,OAAO,CAAA;CAAO,GAC7F,OAAO,CAAC,IAAI,CAAC,CASf;AAED,8FAA8F;AAC9F,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GACpE,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,8CAA8C;AAC9C,wBAAsB,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA+BjE;AAED;;;GAGG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC,CA0EnE"}
|
package/dist/boot.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { InfrastructureError } from './errors.js';
|
|
4
|
+
import { sleep } from './util.js';
|
|
5
|
+
/**
|
|
6
|
+
* How long the port must stay open, with the process still alive, before the boot counts.
|
|
7
|
+
* `next start` (and other servers) open the listener before loading the build, so a port that
|
|
8
|
+
* answers once is not proof of an app that will answer the walk.
|
|
9
|
+
*/
|
|
10
|
+
export const BOOT_SETTLE_MS = 750;
|
|
11
|
+
/** How long a port left behind by the previous app process may take to close before boot refuses it. */
|
|
12
|
+
export const PORT_RELEASE_GRACE_MS = 5_000;
|
|
13
|
+
export function isPortOpen(port, host = '127.0.0.1', timeoutMs = 1000) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const socket = new net.Socket();
|
|
16
|
+
const done = (ok) => {
|
|
17
|
+
socket.destroy();
|
|
18
|
+
resolve(ok);
|
|
19
|
+
};
|
|
20
|
+
socket.setTimeout(timeoutMs);
|
|
21
|
+
socket.once('connect', () => done(true));
|
|
22
|
+
socket.once('timeout', () => done(false));
|
|
23
|
+
socket.once('error', () => done(false));
|
|
24
|
+
socket.connect(port, host);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export async function waitForPort(port, opts = {}) {
|
|
28
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 120_000);
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
if (opts.isAlive && !opts.isAlive())
|
|
31
|
+
throw new InfrastructureError(`app process exited before port ${port} opened`);
|
|
32
|
+
if (await isPortOpen(port, opts.host))
|
|
33
|
+
return;
|
|
34
|
+
await sleep(opts.intervalMs ?? 500);
|
|
35
|
+
}
|
|
36
|
+
throw new InfrastructureError(`timed out waiting for port ${port} to open`);
|
|
37
|
+
}
|
|
38
|
+
/** Resolve once nothing accepts connections on the port any more (or the deadline passes). */
|
|
39
|
+
export async function waitForPortClosed(port, opts = {}) {
|
|
40
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 10_000);
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
if (!(await isPortOpen(port, opts.host)))
|
|
43
|
+
return true;
|
|
44
|
+
await sleep(opts.intervalMs ?? 200);
|
|
45
|
+
}
|
|
46
|
+
return !(await isPortOpen(port, opts.host));
|
|
47
|
+
}
|
|
48
|
+
/** Kill a process and all of its children. */
|
|
49
|
+
export async function killTree(child) {
|
|
50
|
+
if (child.exitCode !== null || child.pid === undefined)
|
|
51
|
+
return;
|
|
52
|
+
const exited = new Promise((resolve) => child.once('exit', () => resolve()));
|
|
53
|
+
if (process.platform === 'win32') {
|
|
54
|
+
await new Promise((resolve) => {
|
|
55
|
+
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
56
|
+
stdio: 'ignore',
|
|
57
|
+
});
|
|
58
|
+
killer.once('exit', () => resolve());
|
|
59
|
+
killer.once('error', () => resolve());
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
try {
|
|
68
|
+
child.kill('SIGTERM');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* already gone */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const grace = sleep(5000).then(() => {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* already gone */
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
await Promise.race([exited, grace]);
|
|
83
|
+
}
|
|
84
|
+
await Promise.race([exited, sleep(10_000)]);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Boot the app under test with `bootCommand` and wait for `port`. The port must be free
|
|
88
|
+
* beforehand: a stray server would walk with the wrong scenario seed.
|
|
89
|
+
*/
|
|
90
|
+
export async function bootApp(opts) {
|
|
91
|
+
const host = opts.host ?? '127.0.0.1';
|
|
92
|
+
const log = opts.log ?? (() => { });
|
|
93
|
+
// A previous scenario's server may still be releasing the port (its process tree was killed a
|
|
94
|
+
// moment ago); give it a few seconds. A server that is really running is still refused.
|
|
95
|
+
if (!(await waitForPortClosed(opts.port, { host, timeoutMs: PORT_RELEASE_GRACE_MS }))) {
|
|
96
|
+
throw new InfrastructureError(`port ${opts.port} is already in use; stop the other server so the walk boots the app with the scenario seed`);
|
|
97
|
+
}
|
|
98
|
+
const lines = [];
|
|
99
|
+
const push = (chunk) => {
|
|
100
|
+
for (const line of chunk.toString('utf8').split(/\r?\n/)) {
|
|
101
|
+
if (!line.trim())
|
|
102
|
+
continue;
|
|
103
|
+
lines.push(line);
|
|
104
|
+
if (lines.length > 200)
|
|
105
|
+
lines.shift();
|
|
106
|
+
log(`[app] ${line}`);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const child = spawn(opts.command, {
|
|
110
|
+
cwd: opts.cwd,
|
|
111
|
+
shell: true,
|
|
112
|
+
detached: process.platform !== 'win32',
|
|
113
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
114
|
+
env: {
|
|
115
|
+
...process.env,
|
|
116
|
+
PORT: String(opts.port),
|
|
117
|
+
HOSTNAME: host,
|
|
118
|
+
...opts.env,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
child.stdout?.on('data', push);
|
|
122
|
+
child.stderr?.on('data', push);
|
|
123
|
+
let exited = false;
|
|
124
|
+
child.once('exit', () => {
|
|
125
|
+
exited = true;
|
|
126
|
+
});
|
|
127
|
+
child.once('error', (err) => {
|
|
128
|
+
lines.push(`spawn error: ${err.message}`);
|
|
129
|
+
exited = true;
|
|
130
|
+
});
|
|
131
|
+
const failBoot = async (message) => {
|
|
132
|
+
await killTree(child);
|
|
133
|
+
const tail = lines.slice(-20).join('\n');
|
|
134
|
+
throw new InfrastructureError(`${message}\ncommand: ${opts.command}${tail ? `\nlast output:\n${tail}` : ''}`);
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
await waitForPort(opts.port, { host, timeoutMs: opts.timeoutMs, isAlive: () => !exited });
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
return failBoot(err instanceof Error ? err.message : String(err));
|
|
141
|
+
}
|
|
142
|
+
// The port opened; make sure the process survives it and the port stays open. A server that
|
|
143
|
+
// listens and then dies (no production build, a crash on first import) must be an
|
|
144
|
+
// infrastructure failure here, never a run full of failed steps.
|
|
145
|
+
await sleep(opts.settleMs ?? BOOT_SETTLE_MS);
|
|
146
|
+
if (exited)
|
|
147
|
+
return failBoot(`app process exited right after port ${opts.port} opened`);
|
|
148
|
+
if (!(await isPortOpen(opts.port, host)))
|
|
149
|
+
return failBoot(`port ${opts.port} closed again right after it opened`);
|
|
150
|
+
return {
|
|
151
|
+
pid: child.pid ?? null,
|
|
152
|
+
// The top-level process (a shell or pnpm) exits before its grandchild releases the socket;
|
|
153
|
+
// the next scenario's boot must find the port closed, so wait for that here.
|
|
154
|
+
stop: async () => {
|
|
155
|
+
await killTree(child);
|
|
156
|
+
await waitForPortClosed(opts.port, { host });
|
|
157
|
+
},
|
|
158
|
+
output: () => [...lines],
|
|
159
|
+
alive: () => !exited,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=boot.js.map
|
package/dist/boot.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"boot.js","sourceRoot":"","sources":["../src/boot.ts"],"names":[],"mappings":"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"}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAwB,MAAM,WAAW,CAAC;AA6B1D,wBAAgB,YAAY,IAAI,OAAO,CA0KtC"}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, InvalidArgumentError } from 'commander';
|
|
3
|
+
import { Env, RunKind } from '@junejuly-lockstep/model';
|
|
4
|
+
import { ContractViolation } from './errors.js';
|
|
5
|
+
import { EXIT, FAIL_ON_VERDICTS_MODES, exitCodeForError, parseFailOnVerdicts, shouldFailOnVerdicts, } from './exitCodes.js';
|
|
6
|
+
import { INIT_FILES, init } from './init.js';
|
|
7
|
+
import { reconcile, validate } from './reconcile.js';
|
|
8
|
+
import { syncModels } from './sync.js';
|
|
9
|
+
import { DETERMINISM_RULES } from './determinism.js';
|
|
10
|
+
import { reconcilerVersion } from './util.js';
|
|
11
|
+
function enumParser(values, label) {
|
|
12
|
+
return (value) => {
|
|
13
|
+
if (values.includes(value))
|
|
14
|
+
return value;
|
|
15
|
+
throw new InvalidArgumentError(`${label} must be one of ${values.join('|')}`);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function intParser(value) {
|
|
19
|
+
const n = Number.parseInt(value, 10);
|
|
20
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
21
|
+
throw new InvalidArgumentError('must be a positive integer');
|
|
22
|
+
return n;
|
|
23
|
+
}
|
|
24
|
+
export function buildProgram() {
|
|
25
|
+
const program = new Command();
|
|
26
|
+
program
|
|
27
|
+
.name('lockstep')
|
|
28
|
+
.description('Lockstep reconciler: extract, snapshot, walk, diff and post a run to the platform; sync journey models on push.')
|
|
29
|
+
.version(reconcilerVersion());
|
|
30
|
+
program
|
|
31
|
+
.command('reconcile')
|
|
32
|
+
.description('Run the full pipeline and post the run (exit 0 clean, 1 contract, 2 infrastructure, 3 verdicts)')
|
|
33
|
+
.option('--ref <sha>', 'commit SHA under test (default: GITHUB_SHA or git HEAD)')
|
|
34
|
+
.option('--kind <kind>', 'deploy|pr', enumParser(RunKind.options, '--kind'), 'deploy')
|
|
35
|
+
.option('--pr <n>', 'pull request number (required for --kind pr unless GITHUB_REF is a PR ref)', intParser)
|
|
36
|
+
.option('--env <env>', 'staging|prod|preview', enumParser(Env.options, '--env'), 'staging')
|
|
37
|
+
.option('--deploy-label <label>', 'deploy label, e.g. d14')
|
|
38
|
+
.option('--config <path>', 'path to lockstep.config.ts', 'lockstep.config.ts')
|
|
39
|
+
.option('--out <dir>', 'local output directory', '.lockstep')
|
|
40
|
+
.option('--dry-run', 'run everything locally; no API calls, no upload, no post', false)
|
|
41
|
+
.option('--fail-on-verdicts <mode>', `${FAIL_ON_VERDICTS_MODES.join('|')} (auto: report-only for pr, fail for deploy)`, 'auto')
|
|
42
|
+
.option('--determinism-check', 'walk twice and fail (exit 2) when any screenshot differs', false)
|
|
43
|
+
.option('--declared-seams <list>', 'comma-separated seams the author declares this change touches')
|
|
44
|
+
.option('--base <ref>', 'git ref to diff the GraphQL SDL against', 'HEAD~1')
|
|
45
|
+
.option('--api-base-url <url>', 'platform API origin to use instead of apiBaseUrl in the config (or LOCKSTEP_API_BASE_URL); for local rehearsals')
|
|
46
|
+
.action(async (o) => {
|
|
47
|
+
try {
|
|
48
|
+
const result = await reconcile({
|
|
49
|
+
ref: o['ref'],
|
|
50
|
+
kind: o['kind'],
|
|
51
|
+
pr: o['pr'],
|
|
52
|
+
env: o['env'],
|
|
53
|
+
deployLabel: o['deployLabel'],
|
|
54
|
+
configPath: o['config'],
|
|
55
|
+
outDir: o['out'],
|
|
56
|
+
apiBaseUrl: o['apiBaseUrl'],
|
|
57
|
+
dryRun: Boolean(o['dryRun']),
|
|
58
|
+
failOnVerdicts: parseFailOnVerdicts(o['failOnVerdicts']),
|
|
59
|
+
determinismCheck: Boolean(o['determinismCheck']),
|
|
60
|
+
declaredSeams: typeof o['declaredSeams'] === 'string'
|
|
61
|
+
? o['declaredSeams']
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((s) => s.trim())
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
: [],
|
|
66
|
+
baseRef: o['base'],
|
|
67
|
+
});
|
|
68
|
+
if (result.response && result.response.judgmentCount > 0) {
|
|
69
|
+
const fails = shouldFailOnVerdicts(o['kind'], parseFailOnVerdicts(o['failOnVerdicts']));
|
|
70
|
+
console.log(`${result.response.judgmentCount} verdict(s) need judgment${fails ? '' : ' (report-only; not failing the job)'}.`);
|
|
71
|
+
}
|
|
72
|
+
process.exitCode = result.exitCode;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
reportError(err);
|
|
76
|
+
process.exitCode = exitCodeForError(err);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
program
|
|
80
|
+
.command('validate')
|
|
81
|
+
.description('Check the client contract only (config, journeys, scenarios, workflow, API key); no walk')
|
|
82
|
+
.option('--config <path>', 'path to lockstep.config.ts', 'lockstep.config.ts')
|
|
83
|
+
.option('--offline', 'do not require LOCKSTEP_API_KEY', false)
|
|
84
|
+
.action(async (o) => {
|
|
85
|
+
try {
|
|
86
|
+
const result = await validate({
|
|
87
|
+
configPath: o['config'],
|
|
88
|
+
offline: Boolean(o['offline']),
|
|
89
|
+
});
|
|
90
|
+
console.log('Lockstep contract check');
|
|
91
|
+
for (const line of result.lines)
|
|
92
|
+
console.log(line);
|
|
93
|
+
console.log('');
|
|
94
|
+
console.log('Determinism rules enforced by the walk:');
|
|
95
|
+
for (const r of DETERMINISM_RULES)
|
|
96
|
+
console.log(` - ${r.rule}: ${r.how}`);
|
|
97
|
+
console.log('');
|
|
98
|
+
console.log(result.ok ? 'Contract satisfied.' : 'Contract NOT satisfied.');
|
|
99
|
+
process.exitCode = result.ok ? EXIT.CLEAN : EXIT.CONTRACT_VIOLATION;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
reportError(err);
|
|
103
|
+
process.exitCode = exitCodeForError(err);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
program
|
|
107
|
+
.command('sync-models')
|
|
108
|
+
.description('Load the journey files and scenarios and post the parsed models to the platform (M3 model sync; run on push to the default branch). Exit 0 ok, 1 contract, 2 infrastructure')
|
|
109
|
+
.option('--ref <sha>', 'commit SHA being synced (default: GITHUB_SHA or git HEAD)')
|
|
110
|
+
.option('--branch <name>', 'branch the commit was pushed to (default: GITHUB_REF_NAME, then the checked-out branch)')
|
|
111
|
+
.option('--pushed-at <iso>', 'commit time, ISO 8601 (default: the commit’s committer date from git, then now)')
|
|
112
|
+
.option('--config <path>', 'path to lockstep.config.ts', 'lockstep.config.ts')
|
|
113
|
+
.option('--out <dir>', 'local output directory', '.lockstep')
|
|
114
|
+
.option('--dry-run', 'load, validate and write the payload locally; no API call', false)
|
|
115
|
+
.action(async (o) => {
|
|
116
|
+
try {
|
|
117
|
+
const result = await syncModels({
|
|
118
|
+
ref: o['ref'],
|
|
119
|
+
branch: o['branch'],
|
|
120
|
+
pushedAt: o['pushedAt'],
|
|
121
|
+
configPath: o['config'],
|
|
122
|
+
outDir: o['out'],
|
|
123
|
+
dryRun: Boolean(o['dryRun']),
|
|
124
|
+
});
|
|
125
|
+
process.exitCode = result.exitCode;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
reportError(err);
|
|
129
|
+
process.exitCode = exitCodeForError(err);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
program
|
|
133
|
+
.command('init')
|
|
134
|
+
.description('Write the example config, journey, scenarios, MSW handlers and the CI workflow')
|
|
135
|
+
.option('--force', 'overwrite existing files', false)
|
|
136
|
+
.option('--dir <path>', 'target directory', process.cwd())
|
|
137
|
+
.action((o) => {
|
|
138
|
+
try {
|
|
139
|
+
const result = init(o['dir'], { force: Boolean(o['force']) });
|
|
140
|
+
for (const f of result.written)
|
|
141
|
+
console.log(` wrote ${f}`);
|
|
142
|
+
for (const f of result.skipped)
|
|
143
|
+
console.log(` kept ${f} (exists; use --force to overwrite)`);
|
|
144
|
+
console.log('');
|
|
145
|
+
console.log('Next steps:');
|
|
146
|
+
for (const f of INIT_FILES)
|
|
147
|
+
console.log(` - ${f.dest}: ${f.purpose}`);
|
|
148
|
+
console.log(' - add LOCKSTEP_API_KEY to the repo secrets, install the Lockstep GitHub App, run `npx lockstep validate`');
|
|
149
|
+
process.exitCode = EXIT.CLEAN;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
reportError(err);
|
|
153
|
+
process.exitCode = EXIT.INFRASTRUCTURE;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
return program;
|
|
157
|
+
}
|
|
158
|
+
function reportError(err) {
|
|
159
|
+
if (err instanceof ContractViolation) {
|
|
160
|
+
console.error('');
|
|
161
|
+
console.error(err.message);
|
|
162
|
+
console.error('');
|
|
163
|
+
console.error('The repo does not meet the Lockstep client contract. See `lockstep validate` and the README checklist.');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
console.error('');
|
|
167
|
+
console.error(err instanceof Error ? `${err.name}: ${err.message}` : String(err));
|
|
168
|
+
if (err instanceof Error && err.stack && process.env['LOCKSTEP_DEBUG'])
|
|
169
|
+
console.error(err.stack);
|
|
170
|
+
}
|
|
171
|
+
const invokedDirectly = typeof process.argv[1] === 'string' && /(^|[\\/])(cli\.(js|ts)|lockstep)$/.test(process.argv[1]);
|
|
172
|
+
if (invokedDirectly) {
|
|
173
|
+
buildProgram()
|
|
174
|
+
.parseAsync(process.argv)
|
|
175
|
+
.catch((err) => {
|
|
176
|
+
reportError(err);
|
|
177
|
+
process.exitCode = exitCodeForError(err);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
|