@qulib/core 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -8
- package/dist/analyze.d.ts.map +1 -1
- package/dist/analyze.js +86 -7
- package/dist/cli/auth-login-resolve.d.ts +14 -0
- package/dist/cli/auth-login-resolve.d.ts.map +1 -0
- package/dist/cli/auth-login-resolve.js +68 -0
- package/dist/cli/auth-login-run.d.ts +13 -0
- package/dist/cli/auth-login-run.d.ts.map +1 -0
- package/dist/cli/auth-login-run.js +152 -0
- package/dist/cli/index.js +60 -7
- package/dist/harness/state-manager.d.ts +10 -0
- package/dist/harness/state-manager.d.ts.map +1 -1
- package/dist/harness/state-manager.js +15 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -6
- package/dist/phases/act.js +3 -3
- package/dist/phases/observe.js +5 -5
- package/dist/phases/think.js +1 -1
- package/dist/schemas/automation-maturity.schema.d.ts +40 -0
- package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
- package/dist/schemas/automation-maturity.schema.js +27 -0
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/schemas/repo-analysis.schema.d.ts +22 -0
- package/dist/schemas/repo-analysis.schema.d.ts.map +1 -1
- package/dist/schemas/repo-analysis.schema.js +1 -0
- package/dist/telemetry/emit.d.ts +22 -0
- package/dist/telemetry/emit.d.ts.map +1 -1
- package/dist/telemetry/emit.js +37 -0
- package/dist/telemetry/telemetry.interface.d.ts +1 -1
- package/dist/telemetry/telemetry.interface.d.ts.map +1 -1
- package/dist/tools/apply-auth.d.ts +4 -0
- package/dist/tools/apply-auth.d.ts.map +1 -0
- package/dist/tools/apply-auth.js +35 -0
- package/dist/tools/auth/apply.d.ts +4 -0
- package/dist/tools/auth/apply.d.ts.map +1 -0
- package/dist/tools/auth/apply.js +35 -0
- package/dist/tools/auth/block-gap.d.ts +9 -0
- package/dist/tools/auth/block-gap.d.ts.map +1 -0
- package/dist/tools/auth/block-gap.js +52 -0
- package/dist/tools/auth/custom-providers.d.ts +15 -0
- package/dist/tools/auth/custom-providers.d.ts.map +1 -0
- package/dist/tools/auth/custom-providers.js +62 -0
- package/dist/tools/auth/detect.d.ts +23 -0
- package/dist/tools/auth/detect.d.ts.map +1 -0
- package/dist/tools/auth/detect.js +526 -0
- package/dist/tools/auth/detector.d.ts +23 -0
- package/dist/tools/auth/detector.d.ts.map +1 -0
- package/dist/tools/auth/detector.js +526 -0
- package/dist/tools/auth/explore.d.ts +4 -0
- package/dist/tools/auth/explore.d.ts.map +1 -0
- package/dist/tools/auth/explore.js +346 -0
- package/dist/tools/auth/explorer.d.ts +4 -0
- package/dist/tools/auth/explorer.d.ts.map +1 -0
- package/dist/tools/auth/explorer.js +346 -0
- package/dist/tools/auth/gaps.d.ts +9 -0
- package/dist/tools/auth/gaps.d.ts.map +1 -0
- package/dist/tools/auth/gaps.js +52 -0
- package/dist/tools/auth/oauth-providers.d.ts +7 -0
- package/dist/tools/auth/oauth-providers.d.ts.map +1 -0
- package/dist/tools/auth/oauth-providers.js +21 -0
- package/dist/tools/auth/providers.d.ts +7 -0
- package/dist/tools/auth/providers.d.ts.map +1 -0
- package/dist/tools/auth/providers.js +21 -0
- package/dist/tools/auth/surface-analyzer.d.ts +4 -0
- package/dist/tools/auth/surface-analyzer.d.ts.map +1 -0
- package/dist/tools/auth/surface-analyzer.js +170 -0
- package/dist/tools/auth/surface.d.ts +4 -0
- package/dist/tools/auth/surface.d.ts.map +1 -0
- package/dist/tools/auth/surface.js +170 -0
- package/dist/tools/auth/user-providers.d.ts +15 -0
- package/dist/tools/auth/user-providers.d.ts.map +1 -0
- package/dist/tools/auth/user-providers.js +62 -0
- package/dist/tools/auth-block-gap.d.ts +6 -0
- package/dist/tools/auth-block-gap.d.ts.map +1 -1
- package/dist/tools/auth-block-gap.js +42 -9
- package/dist/tools/auth-detector.d.ts +19 -0
- package/dist/tools/auth-detector.d.ts.map +1 -1
- package/dist/tools/auth-detector.js +186 -8
- package/dist/tools/automation-maturity.d.ts.map +1 -1
- package/dist/tools/automation-maturity.js +76 -20
- package/dist/tools/explorers/browser.d.ts +3 -0
- package/dist/tools/explorers/browser.d.ts.map +1 -0
- package/dist/tools/explorers/browser.js +13 -0
- package/dist/tools/explorers/cypress-explorer.d.ts +8 -0
- package/dist/tools/explorers/cypress-explorer.d.ts.map +1 -0
- package/dist/tools/explorers/cypress-explorer.js +5 -0
- package/dist/tools/explorers/cypress.d.ts +8 -0
- package/dist/tools/explorers/cypress.d.ts.map +1 -0
- package/dist/tools/explorers/cypress.js +5 -0
- package/dist/tools/explorers/explorer.interface.d.ts +7 -0
- package/dist/tools/explorers/explorer.interface.d.ts.map +1 -0
- package/dist/tools/explorers/explorer.interface.js +1 -0
- package/dist/tools/explorers/factory.d.ts +4 -0
- package/dist/tools/explorers/factory.d.ts.map +1 -0
- package/dist/tools/explorers/factory.js +12 -0
- package/dist/tools/explorers/playwright-explorer.d.ts +8 -0
- package/dist/tools/explorers/playwright-explorer.d.ts.map +1 -0
- package/dist/tools/explorers/playwright-explorer.js +172 -0
- package/dist/tools/explorers/playwright.d.ts +8 -0
- package/dist/tools/explorers/playwright.d.ts.map +1 -0
- package/dist/tools/explorers/playwright.js +172 -0
- package/dist/tools/explorers/types.d.ts +7 -0
- package/dist/tools/explorers/types.d.ts.map +1 -0
- package/dist/tools/explorers/types.js +1 -0
- package/dist/tools/playwright-explorer.js +1 -1
- package/dist/tools/repo/detect-framework.d.ts +15 -0
- package/dist/tools/repo/detect-framework.d.ts.map +1 -0
- package/dist/tools/repo/detect-framework.js +153 -0
- package/dist/tools/repo/framework-detector.d.ts +15 -0
- package/dist/tools/repo/framework-detector.d.ts.map +1 -0
- package/dist/tools/repo/framework-detector.js +153 -0
- package/dist/tools/repo/scan.d.ts +19 -0
- package/dist/tools/repo/scan.d.ts.map +1 -0
- package/dist/tools/repo/scan.js +181 -0
- package/dist/tools/repo/scanner.d.ts +19 -0
- package/dist/tools/repo/scanner.d.ts.map +1 -0
- package/dist/tools/repo/scanner.js +181 -0
- package/dist/tools/repo-scanner.d.ts.map +1 -1
- package/dist/tools/repo-scanner.js +7 -2
- package/dist/tools/scoring/automation-maturity.d.ts +4 -0
- package/dist/tools/scoring/automation-maturity.d.ts.map +1 -0
- package/dist/tools/scoring/automation-maturity.js +219 -0
- package/dist/tools/scoring/gap-engine.d.ts +8 -0
- package/dist/tools/scoring/gap-engine.d.ts.map +1 -0
- package/dist/tools/scoring/gap-engine.js +138 -0
- package/dist/tools/scoring/gaps.d.ts +8 -0
- package/dist/tools/scoring/gaps.d.ts.map +1 -0
- package/dist/tools/scoring/gaps.js +138 -0
- package/dist/tools/scoring/public-surface.d.ts +5 -0
- package/dist/tools/scoring/public-surface.d.ts.map +1 -0
- package/dist/tools/scoring/public-surface.js +13 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -47,6 +47,28 @@ qulib auth init --base-url https://app.example.com
|
|
|
47
47
|
|
|
48
48
|
This opens a real browser. Log in normally (OAuth, magic link, password manager, whatever). Press ENTER in the terminal when you reach a logged-in page. Qulib saves your session to `qulib-storage-state.json`.
|
|
49
49
|
|
|
50
|
+
### Automated form login (`auth login`)
|
|
51
|
+
|
|
52
|
+
When **`detect-auth`** shows **`authOptions`** with **`type: "form-login"`** and **`requirements.method: "credentials"`** (including click-to-reveal paths such as Scholastic Sync), you can save a storage state **without** manual clicking:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
qulib auth login --base-url https://platform.scholastic.com \
|
|
56
|
+
--auth-path scholastic-sync \
|
|
57
|
+
--credentials-file ~/.qulib/scholastic-creds.json \
|
|
58
|
+
--out ~/.qulib/scholastic-state.json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The JSON file must map **field `name`** values from `authOptions` to secrets, e.g. `{"username":"…","password":"…","hidden.datasource":"…"}`. Prefer **`--credentials-file`** over **`--credentials`** so values are not stored in shell history.
|
|
62
|
+
|
|
63
|
+
Then analyze with the saved session:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
qulib analyze --url https://platform.scholastic.com \
|
|
67
|
+
--auth-storage-state ~/.qulib/scholastic-state.json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Use **`--auth-path <id>`** when multiple **`form-login`** paths appear in **`authOptions`**. Use **`--success-url-contains <substring>`** for stricter success detection; otherwise Qulib infers success from URL changes or the password field disappearing (and warns if it cannot confirm).
|
|
71
|
+
|
|
50
72
|
Then scan with it:
|
|
51
73
|
|
|
52
74
|
```bash
|
|
@@ -55,6 +77,24 @@ qulib analyze --url https://app.example.com --auth-storage-state ./qulib-storage
|
|
|
55
77
|
|
|
56
78
|
The storage state is just a JSON file of cookies and localStorage — keep it private, treat it like a credential.
|
|
57
79
|
|
|
80
|
+
#### Storage state is validated before crawl
|
|
81
|
+
|
|
82
|
+
Qulib now validates the provided storage state before doing any work. If the file is missing, unreadable, empty, on the wrong origin, or carries a session that is already expired, Qulib stops with an honest `blocked` result (no fake `releaseConfidence`) and a structured gap explaining how to recover. The validator reports one of these stable reason codes:
|
|
83
|
+
|
|
84
|
+
| Reason code | Meaning |
|
|
85
|
+
| ------------------------- | ----------------------------------------------------------------------- |
|
|
86
|
+
| `missing-file` | Path passed to `--auth-storage-state` does not exist. |
|
|
87
|
+
| `unreadable-file` | File exists but the process can't read it (permissions). |
|
|
88
|
+
| `invalid-json` | File is present and readable but not valid JSON. |
|
|
89
|
+
| `no-auth-cookies` | File parses, but has zero cookies and zero localStorage entries. |
|
|
90
|
+
| `wrong-origin` | Session redirects to a different origin (host/port/scheme mismatch). |
|
|
91
|
+
| `expired-or-unauthorized` | Loaded session shows the login form again, or the app returns 401/403. |
|
|
92
|
+
| `unknown` | Validation could not be completed for an unexpected reason. |
|
|
93
|
+
|
|
94
|
+
Origin matching is strict — `https://app.example` and `https://www.app.example` are different origins, as are `http://localhost:3000` and `http://localhost:4000`. Re-run `qulib auth login` against the same origin you plan to `analyze`.
|
|
95
|
+
|
|
96
|
+
Relatedly, `qulib auth login` will now refuse to save a storage state if the browser ends the flow on a different origin than `--base-url` (a federated/SSO redirect that never returned to the app). This prevents Qulib from quietly persisting an IdP-domain session that would later produce false-confidence scans.
|
|
97
|
+
|
|
58
98
|
### Multi-path auth exploration (`explore-auth`)
|
|
59
99
|
|
|
60
100
|
For unfamiliar apps (especially enterprise SSO with several buttons), run **`qulib explore-auth --url <url>`** before `analyze`. The JSON lists every detected path (built-in OAuth names like Google/Clever, **heuristic** unknown buttons such as tenant-specific SSO labels, password forms, and magic-link copy) plus **`suggestedAgentBehavior`** for the agent.
|
|
@@ -172,16 +212,24 @@ TypeScript (strict, NodeNext), Commander, Zod, Playwright, @axe-core/playwright,
|
|
|
172
212
|
```text
|
|
173
213
|
src/
|
|
174
214
|
adapters/ # test rendering adapters
|
|
175
|
-
analyze.ts
|
|
176
|
-
cli/
|
|
177
|
-
harness/
|
|
178
|
-
llm/
|
|
179
|
-
phases/
|
|
180
|
-
reporters/
|
|
181
|
-
schemas/
|
|
182
|
-
|
|
215
|
+
analyze.ts # programmatic API (also used by @qulib/mcp)
|
|
216
|
+
cli/ # CLI entry
|
|
217
|
+
harness/ # state + decision logging
|
|
218
|
+
llm/ # LLM contracts
|
|
219
|
+
phases/ # observe / think / act
|
|
220
|
+
reporters/ # JSON + Markdown reports
|
|
221
|
+
schemas/ # Zod schemas
|
|
222
|
+
telemetry/ # event sink + URL redaction
|
|
223
|
+
tools/
|
|
224
|
+
auth/ # detection, exploration, validation, providers, gap builders
|
|
225
|
+
explorers/ # browser launch, Playwright/Cypress crawlers, factory
|
|
226
|
+
repo/ # repo scanner, framework detection
|
|
227
|
+
scoring/ # gap engine, automation maturity, public surface
|
|
228
|
+
__tests__/ # integration and wiring tests live in __tests__/ in each folder
|
|
183
229
|
```
|
|
184
230
|
|
|
231
|
+
A contributor map of which folder to touch for each kind of change lives at [`docs/source-map.md`](../../docs/source-map.md).
|
|
232
|
+
|
|
185
233
|
Repo rules: see [`CLAUDE.md`](../../CLAUDE.md).
|
|
186
234
|
|
|
187
235
|
## Configuration
|
package/dist/analyze.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,4BAA4B,CAAC;AACnF,OAAO,KAAK,EAAE,GAAG,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,EAAwB,KAAK,cAAc,EAAE,MAAM,qCAAqC,CAAC;AAChG,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,oCAAoC,CAAC;AAU7F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oCAAoC,CAAC;AAGxE,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAE/D,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,aAAa,CAAC;IACtB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,SAAS,CAAC,EAAE,aAAa,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,aAAa,CAAC;IACtB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,0GAA0G;IAC1G,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,sFAAsF;IACtF,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,WAAW,EAAE,WAAW,CAAC;IACzB,8GAA8G;IAC9G,cAAc,EAAE,cAAc,CAAC;IAC/B,aAAa,EAAE,YAAY,GAAG,IAAI,CAAC;IACnC,WAAW,EAAE,gBAAgB,EAAE,CAAC;IAChC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,0HAA0H;IAC1H,aAAa,EAAE,aAAa,GAAG,IAAI,CAAC;CACrC;AAcD,wBAAsB,UAAU,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,
|
|
1
|
+
{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,4BAA4B,CAAC;AACnF,OAAO,KAAK,EAAE,GAAG,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,EAAwB,KAAK,cAAc,EAAE,MAAM,qCAAqC,CAAC;AAChG,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,oCAAoC,CAAC;AAU7F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oCAAoC,CAAC;AAGxE,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAE/D,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,aAAa,CAAC;IACtB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,SAAS,CAAC,EAAE,aAAa,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,aAAa,CAAC;IACtB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,0GAA0G;IAC1G,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,sFAAsF;IACtF,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,WAAW,EAAE,WAAW,CAAC;IACzB,8GAA8G;IAC9G,cAAc,EAAE,cAAc,CAAC;IAC/B,aAAa,EAAE,YAAY,GAAG,IAAI,CAAC;IACnC,WAAW,EAAE,gBAAgB,EAAE,CAAC;IAChC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,0HAA0H;IAC1H,aAAa,EAAE,aAAa,GAAG,IAAI,CAAC;CACrC;AAcD,wBAAsB,UAAU,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CAqQhF"}
|
package/dist/analyze.js
CHANGED
|
@@ -4,13 +4,13 @@ import { PublicSurfaceSchema } from './schemas/public-surface.schema.js';
|
|
|
4
4
|
import { observe } from './phases/observe.js';
|
|
5
5
|
import { think } from './phases/think.js';
|
|
6
6
|
import { act } from './phases/act.js';
|
|
7
|
-
import { detectAuth } from './tools/auth
|
|
8
|
-
import { analyzeGaps, computeCoverageScore, computeQualityScoreFromGaps } from './tools/
|
|
9
|
-
import { analyzeAuthSurfaceGaps } from './tools/auth
|
|
10
|
-
import { buildPublicSurface } from './tools/public-surface.js';
|
|
11
|
-
import { buildAuthBlockGap } from './tools/auth
|
|
7
|
+
import { detectAuth, validateStorageState } from './tools/auth/detect.js';
|
|
8
|
+
import { analyzeGaps, computeCoverageScore, computeQualityScoreFromGaps } from './tools/scoring/gaps.js';
|
|
9
|
+
import { analyzeAuthSurfaceGaps } from './tools/auth/surface.js';
|
|
10
|
+
import { buildPublicSurface } from './tools/scoring/public-surface.js';
|
|
11
|
+
import { buildAuthBlockGap, buildStorageStateInvalidGap } from './tools/auth/gaps.js';
|
|
12
12
|
import { finalizeGapAnalysisFromDraft } from './phases/think-finalize.js';
|
|
13
|
-
import { emitTelemetry } from './telemetry/emit.js';
|
|
13
|
+
import { emitTelemetry, redactUrlForTelemetry } from './telemetry/emit.js';
|
|
14
14
|
function logScanEnd(progress, result) {
|
|
15
15
|
const rc = result.releaseConfidence === null ? 'null' : String(result.releaseConfidence);
|
|
16
16
|
const cs = result.coverageScore === null ? 'null' : String(result.coverageScore);
|
|
@@ -35,11 +35,90 @@ export async function analyzeApp(options) {
|
|
|
35
35
|
...(progress !== undefined && { progressLog: progress }),
|
|
36
36
|
};
|
|
37
37
|
emitTelemetry(options.telemetry, 'scan.started', sessionId, {
|
|
38
|
-
url: options.url,
|
|
38
|
+
url: redactUrlForTelemetry(options.url),
|
|
39
39
|
maxPagesToScan: options.config.maxPagesToScan,
|
|
40
40
|
hasAuth: Boolean(options.config.auth),
|
|
41
41
|
});
|
|
42
42
|
progress?.info(`Starting scan → ${options.url} maxPagesToScan=${options.config.maxPagesToScan}`);
|
|
43
|
+
if (options.config.auth?.type === 'storage-state') {
|
|
44
|
+
progress?.info('Validating provided storage state before crawl…');
|
|
45
|
+
const validation = await validateStorageState(options.url, options.config.auth.path, options.config.timeoutMs);
|
|
46
|
+
let targetOriginForTelemetry;
|
|
47
|
+
try {
|
|
48
|
+
targetOriginForTelemetry = new URL(options.url).origin;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
targetOriginForTelemetry = '[unparseable-target-url]';
|
|
52
|
+
}
|
|
53
|
+
emitTelemetry(options.telemetry, 'auth.storage-state.validated', sessionId, {
|
|
54
|
+
targetOrigin: targetOriginForTelemetry,
|
|
55
|
+
valid: validation.valid,
|
|
56
|
+
reasonCode: validation.reasonCode,
|
|
57
|
+
storageStateProvided: true,
|
|
58
|
+
});
|
|
59
|
+
if (!validation.valid) {
|
|
60
|
+
progress?.warn(`Storage state rejected (${validation.reasonCode}): ${validation.reason}. Skipping crawl.`);
|
|
61
|
+
decisionLog.push({
|
|
62
|
+
timestamp: new Date().toISOString(),
|
|
63
|
+
phase: 'observe',
|
|
64
|
+
decision: 'storage-state-invalid',
|
|
65
|
+
reason: `${validation.reasonCode}: ${validation.reason}`,
|
|
66
|
+
metadata: {
|
|
67
|
+
reasonCode: validation.reasonCode,
|
|
68
|
+
targetOrigin: targetOriginForTelemetry,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const invalidGap = buildStorageStateInvalidGap({
|
|
72
|
+
url: options.url,
|
|
73
|
+
reasonCode: validation.reasonCode === 'ok' ? 'unknown' : validation.reasonCode,
|
|
74
|
+
reason: validation.reason,
|
|
75
|
+
});
|
|
76
|
+
const draft = {
|
|
77
|
+
analyzedAt: new Date().toISOString(),
|
|
78
|
+
mode: 'auth-required',
|
|
79
|
+
releaseConfidence: 0,
|
|
80
|
+
coveragePagesScanned: 0,
|
|
81
|
+
coverageBudgetExceeded: false,
|
|
82
|
+
coverageWarning: 'auth-required',
|
|
83
|
+
gaps: [invalidGap],
|
|
84
|
+
};
|
|
85
|
+
const costContext = {
|
|
86
|
+
mode: 'auth-required',
|
|
87
|
+
coveragePagesScanned: 0,
|
|
88
|
+
releaseConfidence: 0,
|
|
89
|
+
gaps: [invalidGap],
|
|
90
|
+
};
|
|
91
|
+
const gapAnalysis = await finalizeGapAnalysisFromDraft(draft, options.config, artifacts, costContext);
|
|
92
|
+
const emptyAuthRoutes = RouteInventorySchema.parse({
|
|
93
|
+
scannedAt: new Date().toISOString(),
|
|
94
|
+
baseUrl: options.url,
|
|
95
|
+
routes: [],
|
|
96
|
+
pagesSkipped: 0,
|
|
97
|
+
budgetExceeded: false,
|
|
98
|
+
});
|
|
99
|
+
await act(gapAnalysis, options.config, artifacts);
|
|
100
|
+
const blockedResult = {
|
|
101
|
+
status: 'blocked',
|
|
102
|
+
coverageScore: null,
|
|
103
|
+
releaseConfidence: 0,
|
|
104
|
+
gaps: gapAnalysis.gaps,
|
|
105
|
+
gapAnalysis,
|
|
106
|
+
routeInventory: emptyAuthRoutes,
|
|
107
|
+
repoInventory: null,
|
|
108
|
+
decisionLog,
|
|
109
|
+
publicSurface: null,
|
|
110
|
+
};
|
|
111
|
+
logScanEnd(progress, blockedResult);
|
|
112
|
+
emitTelemetry(options.telemetry, 'scan.blocked', sessionId, {
|
|
113
|
+
status: blockedResult.status,
|
|
114
|
+
coverageScore: blockedResult.coverageScore,
|
|
115
|
+
releaseConfidence: blockedResult.releaseConfidence,
|
|
116
|
+
gapCount: blockedResult.gaps.length,
|
|
117
|
+
reasonCode: validation.reasonCode,
|
|
118
|
+
});
|
|
119
|
+
return blockedResult;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
43
122
|
let detectedAuth;
|
|
44
123
|
let authWall = false;
|
|
45
124
|
if (!options.config.auth && !options.skipAuthDetection) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AuthPath } from '../schemas/config.schema.js';
|
|
2
|
+
export declare function assertExactlyOneCredentialSource(credentials?: string, credentialsFile?: string): void;
|
|
3
|
+
export declare function parseCredentialsJsonString(json: string): Record<string, string>;
|
|
4
|
+
export declare function resolveFormLoginPath(baseUrl: string, authOptions: AuthPath[] | undefined, authPathId?: string): AuthPath;
|
|
5
|
+
export declare function assertCredentialsCoverFields(credentials: Record<string, string>, path: AuthPath): void;
|
|
6
|
+
export declare function resolveAuthLoginConfig(params: {
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
authOptions: AuthPath[] | undefined;
|
|
9
|
+
credentials: Record<string, string>;
|
|
10
|
+
authPathId?: string;
|
|
11
|
+
}): {
|
|
12
|
+
path: AuthPath;
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=auth-login-resolve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-login-resolve.d.ts","sourceRoot":"","sources":["../../src/cli/auth-login-resolve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAE5D,wBAAgB,gCAAgC,CAAC,WAAW,CAAC,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CASrG;AAED,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAkB/E;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,EAAE,GAAG,SAAS,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,QAAQ,CAsBxH;AAED,wBAAgB,4BAA4B,CAAC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAatG;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,QAAQ,EAAE,GAAG,SAAS,CAAC;IACpC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAIrB"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export function assertExactlyOneCredentialSource(credentials, credentialsFile) {
|
|
2
|
+
const hasC = Boolean(credentials && String(credentials).trim().length > 0);
|
|
3
|
+
const hasF = Boolean(credentialsFile && String(credentialsFile).trim().length > 0);
|
|
4
|
+
if (hasC && hasF) {
|
|
5
|
+
throw new Error('Provide either --credentials or --credentials-file, not both.');
|
|
6
|
+
}
|
|
7
|
+
if (!hasC && !hasF) {
|
|
8
|
+
throw new Error('One of --credentials or --credentials-file is required.');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function parseCredentialsJsonString(json) {
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
parsed = JSON.parse(json);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error('Invalid JSON in --credentials');
|
|
18
|
+
}
|
|
19
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
20
|
+
throw new Error('--credentials must be a JSON object mapping field name → value.');
|
|
21
|
+
}
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
24
|
+
if (v === undefined || v === null) {
|
|
25
|
+
throw new Error(`Credential value for "${k}" cannot be null or undefined.`);
|
|
26
|
+
}
|
|
27
|
+
out[k] = String(v);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
export function resolveFormLoginPath(baseUrl, authOptions, authPathId) {
|
|
32
|
+
const formPaths = (authOptions ?? []).filter((o) => o.type === 'form-login' && o.requirements.method === 'credentials');
|
|
33
|
+
if (formPaths.length === 0) {
|
|
34
|
+
throw new Error(`No automatable form-login path detected on ${baseUrl}. Use \`qulib auth init\` for manual login.`);
|
|
35
|
+
}
|
|
36
|
+
if (formPaths.length === 1) {
|
|
37
|
+
return formPaths[0];
|
|
38
|
+
}
|
|
39
|
+
if (!authPathId || !authPathId.trim()) {
|
|
40
|
+
const ids = formPaths.map((p) => p.id).join(', ');
|
|
41
|
+
throw new Error(`Multiple form-login options found: ${ids}. Re-run with --auth-path <id>.`);
|
|
42
|
+
}
|
|
43
|
+
const found = formPaths.find((p) => p.id === authPathId.trim());
|
|
44
|
+
if (!found) {
|
|
45
|
+
const ids = formPaths.map((p) => p.id).join(', ');
|
|
46
|
+
throw new Error(`No form-login authOption with id "${authPathId}". Available: ${ids}.`);
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
export function assertCredentialsCoverFields(credentials, path) {
|
|
51
|
+
if (path.requirements.method !== 'credentials') {
|
|
52
|
+
throw new Error('Internal error: expected credentials requirements on form-login path.');
|
|
53
|
+
}
|
|
54
|
+
const missing = [];
|
|
55
|
+
for (const f of path.requirements.fields) {
|
|
56
|
+
if (!(f.name in credentials) || credentials[f.name] === '') {
|
|
57
|
+
missing.push(f.name);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (missing.length > 0) {
|
|
61
|
+
throw new Error(`Missing credential value(s) for field name(s): ${missing.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function resolveAuthLoginConfig(params) {
|
|
65
|
+
const path = resolveFormLoginPath(params.baseUrl, params.authOptions, params.authPathId);
|
|
66
|
+
assertCredentialsCoverFields(params.credentials, path);
|
|
67
|
+
return { path };
|
|
68
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AuthPath } from '../schemas/config.schema.js';
|
|
2
|
+
export declare function authPathNeedsClickReveal(path: AuthPath): boolean;
|
|
3
|
+
export declare function runAutomatedAuthLogin(params: {
|
|
4
|
+
loginUrl: string;
|
|
5
|
+
path: AuthPath;
|
|
6
|
+
credentials: Record<string, string>;
|
|
7
|
+
outPath: string;
|
|
8
|
+
headed: boolean;
|
|
9
|
+
timeoutMs: number;
|
|
10
|
+
successUrlContains?: string;
|
|
11
|
+
baseUrlHint: string;
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
//# sourceMappingURL=auth-login-run.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-login-run.d.ts","sourceRoot":"","sources":["../../src/cli/auth-login-run.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAsB5D,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAEhE;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,QAAQ,CAAC;IACf,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoIhB"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { BUILT_IN_OAUTH_PROVIDERS } from '../tools/auth/providers.js';
|
|
2
|
+
import { waitForReturnToOrigin } from '../tools/auth/detect.js';
|
|
3
|
+
const builtInOAuthIds = new Set(BUILT_IN_OAUTH_PROVIDERS.map((p) => p.id));
|
|
4
|
+
function escapeRegExp(s) {
|
|
5
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
6
|
+
}
|
|
7
|
+
async function waitNetworkIdleBestEffort(page) {
|
|
8
|
+
try {
|
|
9
|
+
await page.waitForLoadState('networkidle', { timeout: 5000 });
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
/* best-effort */
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function sleep(ms) {
|
|
16
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
17
|
+
}
|
|
18
|
+
export function authPathNeedsClickReveal(path) {
|
|
19
|
+
return path.type === 'form-login' && path.source === 'heuristic' && !builtInOAuthIds.has(path.id);
|
|
20
|
+
}
|
|
21
|
+
export async function runAutomatedAuthLogin(params) {
|
|
22
|
+
const { chromium } = await import('@playwright/test');
|
|
23
|
+
const browser = await chromium.launch({ headless: !params.headed });
|
|
24
|
+
const context = await browser.newContext();
|
|
25
|
+
const page = await context.newPage();
|
|
26
|
+
let confirmed = false;
|
|
27
|
+
try {
|
|
28
|
+
await page.goto(params.loginUrl, { waitUntil: 'domcontentloaded', timeout: params.timeoutMs });
|
|
29
|
+
await waitNetworkIdleBestEffort(page);
|
|
30
|
+
if (authPathNeedsClickReveal(params.path)) {
|
|
31
|
+
try {
|
|
32
|
+
await page.getByRole('button', { name: params.path.label, exact: true }).first().click({ timeout: 2000 });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
await page
|
|
36
|
+
.locator('button')
|
|
37
|
+
.filter({ hasText: new RegExp(`^\\s*${escapeRegExp(params.path.label)}\\s*$`, 'i') })
|
|
38
|
+
.first()
|
|
39
|
+
.click({ timeout: 2000 });
|
|
40
|
+
}
|
|
41
|
+
await page.locator('input[type="password"]').first().waitFor({ state: 'visible', timeout: 2000 });
|
|
42
|
+
}
|
|
43
|
+
if (params.path.requirements.method !== 'credentials') {
|
|
44
|
+
throw new Error('Internal error: expected credentials method on form-login path.');
|
|
45
|
+
}
|
|
46
|
+
for (const field of params.path.requirements.fields) {
|
|
47
|
+
const val = params.credentials[field.name];
|
|
48
|
+
const nameJson = JSON.stringify(field.name);
|
|
49
|
+
const inputByName = `input[name=${nameJson}]`;
|
|
50
|
+
const selectByName = `select[name=${nameJson}]`;
|
|
51
|
+
try {
|
|
52
|
+
if (field.type === 'select') {
|
|
53
|
+
const sel = page.locator(selectByName).first();
|
|
54
|
+
try {
|
|
55
|
+
await sel.selectOption(val, { timeout: 8000 });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
await sel.selectOption({ label: val }, { timeout: 8000 });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else if (field.type === 'checkbox') {
|
|
62
|
+
const loc = page.locator(`input[type="checkbox"][name=${nameJson}]`).first();
|
|
63
|
+
if (val === 'true' || val === '1' || val === 'on' || val === 'yes') {
|
|
64
|
+
await loc.check({ timeout: 8000 });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
await loc.uncheck({ timeout: 8000 });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
await page.locator(inputByName).first().fill(val, { timeout: 8000 });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
76
|
+
throw new Error(`Failed to fill field "${field.name}" (${field.label}): ${msg}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const preSubmit = page.url();
|
|
80
|
+
try {
|
|
81
|
+
await page.locator('button[type="submit"]').first().click({ timeout: 8000 });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
await page.locator('input[type="password"]').first().press('Enter');
|
|
85
|
+
}
|
|
86
|
+
if (params.successUrlContains && params.successUrlContains.trim().length > 0) {
|
|
87
|
+
const frag = params.successUrlContains.trim();
|
|
88
|
+
try {
|
|
89
|
+
await page.waitForURL((u) => u.toString().includes(frag), { timeout: params.timeoutMs });
|
|
90
|
+
confirmed = true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
confirmed = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const t0 = Date.now();
|
|
98
|
+
while (Date.now() - t0 < params.timeoutMs) {
|
|
99
|
+
if (page.url() !== preSubmit) {
|
|
100
|
+
confirmed = true;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
if (Date.now() - t0 >= 5000) {
|
|
104
|
+
const vis = await page.locator('input[type="password"]:visible').count();
|
|
105
|
+
if (vis === 0) {
|
|
106
|
+
confirmed = true;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
await sleep(250);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const originReturn = await waitForReturnToOrigin(page, params.baseUrlHint, params.timeoutMs);
|
|
114
|
+
if (!originReturn.returned) {
|
|
115
|
+
let targetOrigin = '<unknown>';
|
|
116
|
+
let finalOrigin = '<unknown>';
|
|
117
|
+
try {
|
|
118
|
+
targetOrigin = new URL(params.baseUrlHint).origin;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* targetOrigin stays <unknown> */
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
finalOrigin = new URL(originReturn.finalUrl).origin;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* finalOrigin stays <unknown> */
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`Login flow did not return to the app origin (expected ${targetOrigin}, final ${finalOrigin}). ` +
|
|
130
|
+
`Refusing to save the storage state — it would belong to the wrong domain and produce ` +
|
|
131
|
+
`false-confidence scans. Retry the login (the federated provider may need a redirect tweak) ` +
|
|
132
|
+
`or capture the session manually with \`qulib auth init --base-url ${params.baseUrlHint}\`.`);
|
|
133
|
+
}
|
|
134
|
+
if (!confirmed) {
|
|
135
|
+
console.error('[qulib] Could not confirm login success heuristically, but the browser ended on the app origin. ' +
|
|
136
|
+
'Storage state will be saved; verify the session before relying on it (run `qulib analyze` ' +
|
|
137
|
+
'and check that releaseConfidence is not null).');
|
|
138
|
+
}
|
|
139
|
+
const fs = await import('node:fs/promises');
|
|
140
|
+
const pathMod = await import('node:path');
|
|
141
|
+
const outAbs = pathMod.resolve(params.outPath);
|
|
142
|
+
await fs.mkdir(pathMod.dirname(outAbs), { recursive: true });
|
|
143
|
+
await context.storageState({ path: outAbs });
|
|
144
|
+
console.log(`\n[qulib] Saved storage state to ${outAbs}`);
|
|
145
|
+
console.log('[qulib] To use it, pass to qulib like:');
|
|
146
|
+
console.log(` qulib analyze --url ${params.baseUrlHint} --auth-storage-state ${outAbs}`);
|
|
147
|
+
console.log(`[qulib] Or in MCP, pass auth: { type: 'storage-state', path: '${outAbs}' }`);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
await browser.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
4
5
|
import { pathToFileURL } from 'node:url';
|
|
5
6
|
import { z } from 'zod';
|
|
7
|
+
const requirePkg = createRequire(import.meta.url);
|
|
8
|
+
const pkg = requirePkg('../../package.json');
|
|
6
9
|
import { HarnessConfigSchema } from '../schemas/config.schema.js';
|
|
7
10
|
import { analyzeApp } from '../analyze.js';
|
|
8
|
-
import { detectAuth } from '../tools/auth
|
|
9
|
-
import { exploreAuth } from '../tools/auth
|
|
11
|
+
import { detectAuth } from '../tools/auth/detect.js';
|
|
12
|
+
import { exploreAuth } from '../tools/auth/explore.js';
|
|
13
|
+
import { assertExactlyOneCredentialSource, parseCredentialsJsonString, resolveAuthLoginConfig, } from './auth-login-resolve.js';
|
|
14
|
+
import { runAutomatedAuthLogin } from './auth-login-run.js';
|
|
10
15
|
const program = new Command();
|
|
11
16
|
const AnalyzeUrlSchema = z.string().url();
|
|
12
17
|
const FormLoginCliSchema = z.object({
|
|
@@ -28,11 +33,14 @@ function redactConfigForLog(config) {
|
|
|
28
33
|
base.auth = {
|
|
29
34
|
...config.auth,
|
|
30
35
|
credentials: {
|
|
31
|
-
username:
|
|
36
|
+
username: '***',
|
|
32
37
|
password: '***',
|
|
33
38
|
},
|
|
34
39
|
};
|
|
35
40
|
}
|
|
41
|
+
if (config.auth?.type === 'storage-state') {
|
|
42
|
+
base.auth = { type: 'storage-state', path: '<provided>' };
|
|
43
|
+
}
|
|
36
44
|
return base;
|
|
37
45
|
}
|
|
38
46
|
function mergeAuthFromCli(config, options) {
|
|
@@ -109,7 +117,7 @@ async function runAnalyze(options) {
|
|
|
109
117
|
program
|
|
110
118
|
.name('qulib')
|
|
111
119
|
.description('Qulib — QA harness')
|
|
112
|
-
.version(
|
|
120
|
+
.version(pkg.version);
|
|
113
121
|
program
|
|
114
122
|
.command('clean')
|
|
115
123
|
.description('Remove all generated reports and scan state')
|
|
@@ -207,7 +215,7 @@ providersCmd
|
|
|
207
215
|
.command('list')
|
|
208
216
|
.description('List user-local providers registered on this machine')
|
|
209
217
|
.action(async () => {
|
|
210
|
-
const { listUserProviders } = await import('../tools/
|
|
218
|
+
const { listUserProviders } = await import('../tools/auth/custom-providers.js');
|
|
211
219
|
const providers = listUserProviders();
|
|
212
220
|
console.log(JSON.stringify(providers, null, 2));
|
|
213
221
|
});
|
|
@@ -224,7 +232,7 @@ providersCmd
|
|
|
224
232
|
catch {
|
|
225
233
|
throw new Error(`Invalid regex pattern: ${opts.pattern}`);
|
|
226
234
|
}
|
|
227
|
-
const { addUserProvider } = await import('../tools/
|
|
235
|
+
const { addUserProvider } = await import('../tools/auth/custom-providers.js');
|
|
228
236
|
addUserProvider({ id: opts.id, label: opts.label, pattern: opts.pattern });
|
|
229
237
|
console.log(`[qulib] Added provider "${opts.label}" (id: ${opts.id}) to ~/.qulib/providers.json`);
|
|
230
238
|
});
|
|
@@ -233,7 +241,7 @@ providersCmd
|
|
|
233
241
|
.description('Remove a user-local provider by id')
|
|
234
242
|
.requiredOption('--id <id>', 'Provider id to remove')
|
|
235
243
|
.action(async (opts) => {
|
|
236
|
-
const { removeUserProvider } = await import('../tools/
|
|
244
|
+
const { removeUserProvider } = await import('../tools/auth/custom-providers.js');
|
|
237
245
|
const removed = removeUserProvider(opts.id);
|
|
238
246
|
console.log(removed ? `[qulib] Removed "${opts.id}"` : `[qulib] No provider with id "${opts.id}" found`);
|
|
239
247
|
});
|
|
@@ -265,6 +273,7 @@ authCmd
|
|
|
265
273
|
const fs = await import('node:fs/promises');
|
|
266
274
|
const pathMod = await import('node:path');
|
|
267
275
|
const outPath = pathMod.resolve(options.out);
|
|
276
|
+
await fs.mkdir(pathMod.dirname(outPath), { recursive: true });
|
|
268
277
|
await context.storageState({ path: outPath });
|
|
269
278
|
console.log(`\n[qulib] Saved storage state to ${outPath}`);
|
|
270
279
|
console.log('[qulib] To use it, pass to qulib like:');
|
|
@@ -273,6 +282,50 @@ authCmd
|
|
|
273
282
|
await browser.close();
|
|
274
283
|
process.exit(0);
|
|
275
284
|
});
|
|
285
|
+
authCmd
|
|
286
|
+
.command('login')
|
|
287
|
+
.description('Detect form-login on the URL, fill credentials, and save the storage state automatically (uses selectors from detect-auth)')
|
|
288
|
+
.requiredOption('--base-url <url>', 'The base URL of the app to log into')
|
|
289
|
+
.option('--auth-path <id>', 'Specific authOption id to use (e.g. "scholastic-sync") when multiple form-login paths exist')
|
|
290
|
+
.option('--credentials <json>', 'JSON object mapping field name → value, e.g. \'{"username":"a","password":"b","hidden.datasource":"NYC"}\'')
|
|
291
|
+
.option('--credentials-file <path>', 'Path to a JSON file with the credentials object (keeps secrets out of shell history)')
|
|
292
|
+
.option('--out <path>', 'Output file path for the storage state JSON', './qulib-storage-state.json')
|
|
293
|
+
.option('--success-url-contains <substring>', 'Substring that must appear in the URL after login (stronger success detection). If omitted, success is inferred from navigation or hidden password fields.')
|
|
294
|
+
.option('--timeout <ms>', 'Max time in ms to wait for navigation / success heuristics', '30000')
|
|
295
|
+
.option('--headed', 'Run Chromium headed for debugging', false)
|
|
296
|
+
.action(async (options) => {
|
|
297
|
+
assertExactlyOneCredentialSource(options.credentials, options.credentialsFile);
|
|
298
|
+
const fs = await import('node:fs/promises');
|
|
299
|
+
let credentials;
|
|
300
|
+
if (options.credentialsFile && options.credentialsFile.trim()) {
|
|
301
|
+
const p = resolve(options.credentialsFile.trim());
|
|
302
|
+
const raw = await fs.readFile(p, 'utf8');
|
|
303
|
+
credentials = parseCredentialsJsonString(raw);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
credentials = parseCredentialsJsonString(options.credentials.trim());
|
|
307
|
+
}
|
|
308
|
+
const timeoutMs = parseInt(options.timeout, 10);
|
|
309
|
+
const detection = await detectAuth(options.baseUrl, timeoutMs);
|
|
310
|
+
const { path } = resolveAuthLoginConfig({
|
|
311
|
+
baseUrl: options.baseUrl,
|
|
312
|
+
authOptions: detection.authOptions,
|
|
313
|
+
credentials,
|
|
314
|
+
authPathId: options.authPath,
|
|
315
|
+
});
|
|
316
|
+
const loginUrl = detection.loginUrl ?? options.baseUrl;
|
|
317
|
+
await runAutomatedAuthLogin({
|
|
318
|
+
loginUrl,
|
|
319
|
+
path,
|
|
320
|
+
credentials,
|
|
321
|
+
outPath: options.out,
|
|
322
|
+
headed: Boolean(options.headed),
|
|
323
|
+
timeoutMs,
|
|
324
|
+
successUrlContains: options.successUrlContains,
|
|
325
|
+
baseUrlHint: options.baseUrl,
|
|
326
|
+
});
|
|
327
|
+
process.exit(0);
|
|
328
|
+
});
|
|
276
329
|
program.parseAsync().catch((error) => {
|
|
277
330
|
const message = error instanceof Error ? error.message : String(error);
|
|
278
331
|
console.error('[qulib] Failed:', message);
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { ZodSchema } from 'zod';
|
|
2
2
|
export declare function resolveScanStateBaseDir(outputDir?: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Where qulib writes `report.json` and `report.md` for non-ephemeral runs.
|
|
5
|
+
*
|
|
6
|
+
* When `outputDir` is set in HarnessConfig, both scan state and reports share that
|
|
7
|
+
* directory (state files and report files have non-overlapping names). When unset,
|
|
8
|
+
* reports default to `<cwd>/output` (the legacy default) while state defaults to
|
|
9
|
+
* `<cwd>/.scan-state` — separate so a casual `git add output/` doesn't accidentally
|
|
10
|
+
* commit scan state.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveReportDir(outputDir?: string): string;
|
|
3
13
|
export declare class StateManager {
|
|
4
14
|
private readonly stateDir;
|
|
5
15
|
constructor(scanStateBaseDir?: string);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state-manager.d.ts","sourceRoot":"","sources":["../../src/harness/state-manager.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,KAAK,CAAC;AAErC,wBAAgB,uBAAuB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAKlE;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;gBAEtB,gBAAgB,CAAC,EAAE,MAAM;IAI/B,SAAS,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IA8BhE,UAAU,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;CAiBpF"}
|
|
1
|
+
{"version":3,"file":"state-manager.d.ts","sourceRoot":"","sources":["../../src/harness/state-manager.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,KAAK,CAAC;AAErC,wBAAgB,uBAAuB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAKlE;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAK3D;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;gBAEtB,gBAAgB,CAAC,EAAE,MAAM;IAI/B,SAAS,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IA8BhE,UAAU,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;CAiBpF"}
|
|
@@ -8,6 +8,21 @@ export function resolveScanStateBaseDir(outputDir) {
|
|
|
8
8
|
}
|
|
9
9
|
return isAbsolute(outputDir) ? resolve(outputDir) : resolve(process.cwd(), outputDir);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Where qulib writes `report.json` and `report.md` for non-ephemeral runs.
|
|
13
|
+
*
|
|
14
|
+
* When `outputDir` is set in HarnessConfig, both scan state and reports share that
|
|
15
|
+
* directory (state files and report files have non-overlapping names). When unset,
|
|
16
|
+
* reports default to `<cwd>/output` (the legacy default) while state defaults to
|
|
17
|
+
* `<cwd>/.scan-state` — separate so a casual `git add output/` doesn't accidentally
|
|
18
|
+
* commit scan state.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveReportDir(outputDir) {
|
|
21
|
+
if (outputDir === undefined || outputDir === '') {
|
|
22
|
+
return join(process.cwd(), 'output');
|
|
23
|
+
}
|
|
24
|
+
return isAbsolute(outputDir) ? resolve(outputDir) : resolve(process.cwd(), outputDir);
|
|
25
|
+
}
|
|
11
26
|
export class StateManager {
|
|
12
27
|
stateDir;
|
|
13
28
|
constructor(scanStateBaseDir) {
|