pi-lean-dimension 0.5.0 → 0.6.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/README.md +5 -3
- package/node_modules/pi-lean-host/AGENTS.md +13 -6
- package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
- package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
- package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
- package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
- package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
- package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
- package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
- package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
- package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
- package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
- package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
- package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
- package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
- package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
- package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
- package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
- package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
- package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
- package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
- package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
- package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
- package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
- package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
- package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
- package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
- package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
- package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
- package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
- package/node_modules/pi-lean-host/core/auth.ts +2 -1
- package/node_modules/pi-lean-host/core/helpers.ts +29 -35
- package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
- package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
- package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
- package/node_modules/pi-lean-host/core/transport.ts +1 -1
- package/node_modules/pi-lean-host/package.json +1 -1
- package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
- package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
- package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
- package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
- package/node_modules/pi-lean-host/tools/utils.ts +69 -0
- package/node_modules/pi-lean-portal/AGENTS.md +4 -3
- package/node_modules/pi-lean-portal/README.md +9 -9
- package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
- package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
- package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
- package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
- package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
- package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
- package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
- package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
- package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
- package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
- package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
- package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
- package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
- package/node_modules/pi-lean-portal/browser-install.ts +454 -0
- package/node_modules/pi-lean-portal/browser-status.ts +21 -0
- package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
- package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
- package/node_modules/pi-lean-portal/core/router.ts +7 -32
- package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
- package/node_modules/pi-lean-portal/index.ts +1 -18
- package/node_modules/pi-lean-portal/package.json +2 -1
- package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
- package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
- package/node_modules/pi-lean-search/AGENTS.md +5 -3
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
- package/node_modules/pi-lean-search/index.ts +0 -3
- package/node_modules/pi-lean-search/package.json +1 -1
- package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
- package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/package.json +1 -1
- package/package.json +4 -4
|
@@ -4,24 +4,14 @@
|
|
|
4
4
|
* Covers:
|
|
5
5
|
* - BOE worked example parses to a valid ApiGuide with defaults filled.
|
|
6
6
|
* - Each malformed fixture returns a ParseError with dotted field path.
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
7
|
+
*
|
|
8
|
+
* (projectToGuide/slug/loader/catalog/stampFrontmatterField tests live in
|
|
9
|
+
* guide-catalog.test.ts.)
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { describe, it, expect
|
|
13
|
-
import {
|
|
14
|
-
mkdtempSync,
|
|
15
|
-
mkdirSync,
|
|
16
|
-
writeFileSync,
|
|
17
|
-
rmSync,
|
|
18
|
-
renameSync,
|
|
19
|
-
} from "node:fs";
|
|
20
|
-
import { tmpdir } from "node:os";
|
|
21
|
-
import { join } from "node:path";
|
|
12
|
+
import { describe, it, expect } from "vitest";
|
|
22
13
|
import {
|
|
23
14
|
parseApiGuide,
|
|
24
|
-
stampFrontmatterField,
|
|
25
15
|
PAGINATION_ALLOWLISTS,
|
|
26
16
|
AUTH_ALLOWLISTS,
|
|
27
17
|
PARAM_SPEC_KEYS,
|
|
@@ -29,12 +19,6 @@ import {
|
|
|
29
19
|
OP_ALLOWLIST,
|
|
30
20
|
RESPONSE_SHAPE_ALLOWLIST,
|
|
31
21
|
} from "../core/parse-api-guide.js";
|
|
32
|
-
import {
|
|
33
|
-
projectToGuide,
|
|
34
|
-
loadApiGuidesFromDir,
|
|
35
|
-
formatApiGuideCatalog,
|
|
36
|
-
} from "../core/guide-catalog.js";
|
|
37
|
-
import { slug } from "../core/path-template.js";
|
|
38
22
|
import { parse as yamlParse } from "yaml";
|
|
39
23
|
import {
|
|
40
24
|
GATHER_ALL_MAX_FALLBACK,
|
|
@@ -2066,10 +2050,6 @@ body
|
|
|
2066
2050
|
});
|
|
2067
2051
|
});
|
|
2068
2052
|
|
|
2069
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2070
|
-
// projectToGuide — strips recipe, retains presentation + kind
|
|
2071
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2072
|
-
|
|
2073
2053
|
// ═════════════════════════════════════════════════════════════════
|
|
2074
2054
|
// errorPath — present-only-on-error envelope path
|
|
2075
2055
|
// ═════════════════════════════════════════════════════════════════
|
|
@@ -2175,391 +2155,6 @@ body
|
|
|
2175
2155
|
});
|
|
2176
2156
|
});
|
|
2177
2157
|
|
|
2178
|
-
describe("projectToGuide", () => {
|
|
2179
|
-
it("strips recipe fields and retains kind: api", () => {
|
|
2180
|
-
const guide = expectOk(BOE_RECIPE, { filename: "boe.es" });
|
|
2181
|
-
const proj = projectToGuide(guide);
|
|
2182
|
-
|
|
2183
|
-
const keys = Object.keys(proj);
|
|
2184
|
-
const RECIPE_KEYS = [
|
|
2185
|
-
"apiHost",
|
|
2186
|
-
"operations",
|
|
2187
|
-
"pagination",
|
|
2188
|
-
"auth",
|
|
2189
|
-
"helper",
|
|
2190
|
-
"verified",
|
|
2191
|
-
"gatherAllMax",
|
|
2192
|
-
"responseShape",
|
|
2193
|
-
];
|
|
2194
|
-
for (const k of RECIPE_KEYS) {
|
|
2195
|
-
expect(keys).not.toContain(k);
|
|
2196
|
-
}
|
|
2197
|
-
|
|
2198
|
-
expect(proj.kind).toBe("api");
|
|
2199
|
-
expect(proj.domains).toEqual(["boe.es", "www.boe.es"]);
|
|
2200
|
-
expect(proj.icon).toBe("⚖️");
|
|
2201
|
-
expect(proj.shortName).toBe("BOE");
|
|
2202
|
-
expect(proj.updated).toBe("2026-07-17");
|
|
2203
|
-
expect(proj.content).toContain("BOE Legislación Consolidada");
|
|
2204
|
-
expect(proj.category).toBe("site");
|
|
2205
|
-
});
|
|
2206
|
-
|
|
2207
|
-
it("projection carries no helper reference", () => {
|
|
2208
|
-
const guide = expectOk(BOE_RECIPE, { filename: "boe.es" });
|
|
2209
|
-
const proj = projectToGuide(guide);
|
|
2210
|
-
// Helper is a recipe (op-level) field; the Guide projection has no such key.
|
|
2211
|
-
expect("helper" in proj).toBe(false);
|
|
2212
|
-
expect("operations" in proj).toBe(false);
|
|
2213
|
-
});
|
|
2214
|
-
});
|
|
2215
|
-
|
|
2216
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2217
|
-
// slug() — the shortName → identity-folder sanitizer
|
|
2218
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2219
|
-
|
|
2220
|
-
describe("slug()", () => {
|
|
2221
|
-
it("lowercases and replaces non-[a-z0-9-] runs with a single '-'", () => {
|
|
2222
|
-
expect(slug("BOE")).toBe("boe");
|
|
2223
|
-
expect(slug("My Provider Pro!")).toBe("my-provider-pro");
|
|
2224
|
-
expect(slug("Example Dev Inc")).toBe("example-dev-inc");
|
|
2225
|
-
expect(slug("a/b")).toBe("a-b");
|
|
2226
|
-
});
|
|
2227
|
-
|
|
2228
|
-
it("transliterates Latin diacritics instead of dropping them", () => {
|
|
2229
|
-
expect(slug("Café")).toBe("cafe");
|
|
2230
|
-
expect(slug("Überwald")).toBe("uberwald");
|
|
2231
|
-
expect(slug("Bjørk")).toBe("bjork");
|
|
2232
|
-
expect(slug("Münchhausen")).toBe("munchhausen");
|
|
2233
|
-
});
|
|
2234
|
-
|
|
2235
|
-
it("collapses repeated '-' and strips leading/trailing '-'", () => {
|
|
2236
|
-
expect(slug("a--b")).toBe("a-b");
|
|
2237
|
-
expect(slug("-foo-")).toBe("foo");
|
|
2238
|
-
});
|
|
2239
|
-
|
|
2240
|
-
it("slug-collision pair: api_dev_full and api-dev-full both slug to api-dev-full", () => {
|
|
2241
|
-
expect(slug("api_dev_full")).toBe("api-dev-full");
|
|
2242
|
-
expect(slug("api-dev-full")).toBe("api-dev-full");
|
|
2243
|
-
});
|
|
2244
|
-
|
|
2245
|
-
it("throws on empty or all-symbol shortName (slug flattens to empty)", () => {
|
|
2246
|
-
expect(() => slug("")).toThrow(/shortName/);
|
|
2247
|
-
expect(() => slug("!!!")).toThrow(/shortName/);
|
|
2248
|
-
expect(() => slug("..")).toThrow(/shortName/);
|
|
2249
|
-
});
|
|
2250
|
-
});
|
|
2251
|
-
|
|
2252
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2253
|
-
// Loader + catalog — one malformed guide doesn't block the store
|
|
2254
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2255
|
-
|
|
2256
|
-
describe("loadApiGuidesFromDir + formatApiGuideCatalog", () => {
|
|
2257
|
-
beforeEach(() => {
|
|
2258
|
-
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
2259
|
-
});
|
|
2260
|
-
afterEach(() => {
|
|
2261
|
-
vi.restoreAllMocks();
|
|
2262
|
-
});
|
|
2263
|
-
|
|
2264
|
-
it("lists a healthy and a malformed guide together", () => {
|
|
2265
|
-
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
2266
|
-
try {
|
|
2267
|
-
// Healthy guide in subdirectory
|
|
2268
|
-
const boeDir = join(dir, "boe");
|
|
2269
|
-
mkdirSync(boeDir, { recursive: true });
|
|
2270
|
-
writeFileSync(join(boeDir, "guide.md"), BOE_RECIPE);
|
|
2271
|
-
|
|
2272
|
-
// Malformed guide in subdirectory
|
|
2273
|
-
const brokenDir = join(dir, "broken");
|
|
2274
|
-
mkdirSync(brokenDir, { recursive: true });
|
|
2275
|
-
writeFileSync(
|
|
2276
|
-
join(brokenDir, "guide.md"),
|
|
2277
|
-
`---
|
|
2278
|
-
schemaVersion: 1
|
|
2279
|
-
domains: [broken.com]
|
|
2280
|
-
apiHost: https://api.broken.com
|
|
2281
|
-
operations:
|
|
2282
|
-
- name: get
|
|
2283
|
-
via: restPost
|
|
2284
|
-
path: /things
|
|
2285
|
-
---
|
|
2286
|
-
body
|
|
2287
|
-
`,
|
|
2288
|
-
);
|
|
2289
|
-
|
|
2290
|
-
const loaded = loadApiGuidesFromDir(dir);
|
|
2291
|
-
expect(Object.keys(loaded.guides)).toEqual(["boe"]);
|
|
2292
|
-
expect(loaded.malformed).toHaveLength(1);
|
|
2293
|
-
expect(loaded.malformed[0]!.filename).toBe("broken");
|
|
2294
|
-
expect(loaded.malformed[0]!.error.field).toBe("operations[0].via");
|
|
2295
|
-
|
|
2296
|
-
const catalog = formatApiGuideCatalog(loaded);
|
|
2297
|
-
expect(catalog).toContain("BOE");
|
|
2298
|
-
expect(catalog).toContain("⚠ malformed");
|
|
2299
|
-
expect(catalog).toContain("broken");
|
|
2300
|
-
expect(catalog).toContain("operations[0].via");
|
|
2301
|
-
} finally {
|
|
2302
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2303
|
-
}
|
|
2304
|
-
});
|
|
2305
|
-
|
|
2306
|
-
it("collapses the catalog by organization (org line + orgless fallback)", () => {
|
|
2307
|
-
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
2308
|
-
try {
|
|
2309
|
-
const orgRecipe = (d: string, shortName: string, domains: string) => `---
|
|
2310
|
-
schemaVersion: 1
|
|
2311
|
-
kind: api
|
|
2312
|
-
domains: [${domains}]
|
|
2313
|
-
organization: archive.org
|
|
2314
|
-
description: ${shortName} surface.
|
|
2315
|
-
icon: 🏛️
|
|
2316
|
-
shortName: ${shortName}
|
|
2317
|
-
updated: 2026-07-17
|
|
2318
|
-
apiHost: https://${d}
|
|
2319
|
-
verified: 2026-07-17
|
|
2320
|
-
gatherAllMax: 500
|
|
2321
|
-
auth:
|
|
2322
|
-
kind: none
|
|
2323
|
-
responseShape:
|
|
2324
|
-
format: json
|
|
2325
|
-
charset: utf-8
|
|
2326
|
-
operations:
|
|
2327
|
-
- name: get
|
|
2328
|
-
via: restGet
|
|
2329
|
-
path: /x
|
|
2330
|
-
accept: json
|
|
2331
|
-
---
|
|
2332
|
-
org guide.
|
|
2333
|
-
`;
|
|
2334
|
-
for (const [domain, folder, shortName] of [
|
|
2335
|
-
["archive.org", "archive", "Archive"],
|
|
2336
|
-
["web.archive.org", "wayback", "Wayback"],
|
|
2337
|
-
] as const) {
|
|
2338
|
-
mkdirSync(join(dir, folder), { recursive: true });
|
|
2339
|
-
writeFileSync(
|
|
2340
|
-
join(dir, folder, "guide.md"),
|
|
2341
|
-
orgRecipe(domain, shortName, domain),
|
|
2342
|
-
);
|
|
2343
|
-
}
|
|
2344
|
-
// Orgless guide keeps the per-guide line (fallback).
|
|
2345
|
-
mkdirSync(join(dir, "boe"), { recursive: true });
|
|
2346
|
-
writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
|
|
2347
|
-
|
|
2348
|
-
const loaded = loadApiGuidesFromDir(dir);
|
|
2349
|
-
const catalog = formatApiGuideCatalog(loaded);
|
|
2350
|
-
// One org-collapsed line for archive.org with guide count + domain set.
|
|
2351
|
-
expect(catalog).toContain(
|
|
2352
|
-
"🏛️ archive.org — 2 guides (archive.org, web.archive.org)",
|
|
2353
|
-
);
|
|
2354
|
-
// Orgless BOE keeps the per-guide shape (icon + shortName + ops).
|
|
2355
|
-
expect(catalog).toContain("⚖️ BOE — boe.es, www.boe.es");
|
|
2356
|
-
expect(catalog).not.toContain("🏛️ BOE");
|
|
2357
|
-
// Footer mentions the disambiguation menu.
|
|
2358
|
-
expect(catalog).toContain("disambiguation menu");
|
|
2359
|
-
} finally {
|
|
2360
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2361
|
-
}
|
|
2362
|
-
});
|
|
2363
|
-
|
|
2364
|
-
it("returns empty result for a nonexistent directory", () => {
|
|
2365
|
-
const loaded = loadApiGuidesFromDir(
|
|
2366
|
-
join(tmpdir(), "host-guides-nonexistent-xyz"),
|
|
2367
|
-
);
|
|
2368
|
-
expect(loaded.guides).toEqual({});
|
|
2369
|
-
expect(loaded.malformed).toEqual([]);
|
|
2370
|
-
expect(formatApiGuideCatalog(loaded)).toContain("no guides");
|
|
2371
|
-
});
|
|
2372
|
-
|
|
2373
|
-
it("skips subdirectories without guide.md", () => {
|
|
2374
|
-
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
2375
|
-
try {
|
|
2376
|
-
// A subdir without guide.md — skipped
|
|
2377
|
-
mkdirSync(join(dir, "no-guide"), { recursive: true });
|
|
2378
|
-
writeFileSync(join(dir, "no-guide", "helper.ts"), "export default p => p;");
|
|
2379
|
-
|
|
2380
|
-
// A valid subdir with guide.md — loaded
|
|
2381
|
-
mkdirSync(join(dir, "boe"), { recursive: true });
|
|
2382
|
-
writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
|
|
2383
|
-
|
|
2384
|
-
// Flat .md files at top level — ignored
|
|
2385
|
-
writeFileSync(join(dir, "README.txt"), "not a guide");
|
|
2386
|
-
|
|
2387
|
-
const loaded = loadApiGuidesFromDir(dir);
|
|
2388
|
-
expect(Object.keys(loaded.guides)).toEqual(["boe"]);
|
|
2389
|
-
} finally {
|
|
2390
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2391
|
-
}
|
|
2392
|
-
});
|
|
2393
|
-
|
|
2394
|
-
it("routes a divergent folder (entry !== slug(shortName)) to malformed", () => {
|
|
2395
|
-
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
2396
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
2397
|
-
try {
|
|
2398
|
-
// BOE_RECIPE has shortName: BOE → slug "boe"; folder is "boe.es" —
|
|
2399
|
-
// the pre-migration state. Under enforcement the guide does NOT load.
|
|
2400
|
-
mkdirSync(join(dir, "boe.es"), { recursive: true });
|
|
2401
|
-
writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
|
|
2402
|
-
|
|
2403
|
-
const loaded = loadApiGuidesFromDir(dir);
|
|
2404
|
-
expect(Object.keys(loaded.guides)).toEqual([]);
|
|
2405
|
-
expect(loaded.malformed).toHaveLength(1);
|
|
2406
|
-
expect(loaded.malformed[0]!.filename).toBe("boe.es");
|
|
2407
|
-
expect(loaded.malformed[0]!.error.field).toBe("shortName");
|
|
2408
|
-
expect(loaded.malformed[0]!.error.found).toBe("folder 'boe.es'");
|
|
2409
|
-
expect(loaded.malformed[0]!.error.fix).toContain("mv");
|
|
2410
|
-
expect(loaded.malformed[0]!.error.fix).toContain("boe");
|
|
2411
|
-
// The per-guide fix names the mv only; no /reload instruction on the
|
|
2412
|
-
// fix line itself.
|
|
2413
|
-
expect(loaded.malformed[0]!.error.fix).not.toContain("/reload");
|
|
2414
|
-
// The malformed guide is warned about at load, and the catalog
|
|
2415
|
-
// renders its actionable fix.
|
|
2416
|
-
const msg = warn.mock.calls.map((c) => String(c[0])).join("\n");
|
|
2417
|
-
expect(msg).toContain("Malformed guide");
|
|
2418
|
-
expect(msg).toContain("boe.es");
|
|
2419
|
-
const catalog = formatApiGuideCatalog(loaded);
|
|
2420
|
-
expect(catalog).toContain("fix:");
|
|
2421
|
-
expect(catalog).not.toContain("/reload");
|
|
2422
|
-
} finally {
|
|
2423
|
-
warn.mockRestore();
|
|
2424
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2425
|
-
}
|
|
2426
|
-
});
|
|
2427
|
-
|
|
2428
|
-
it("routes warnings through the notify callback when provided", () => {
|
|
2429
|
-
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
2430
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
2431
|
-
const notify = vi.fn();
|
|
2432
|
-
try {
|
|
2433
|
-
// Divergent folder (boe.es vs slug "boe") — must surface loudly.
|
|
2434
|
-
mkdirSync(join(dir, "boe.es"), { recursive: true });
|
|
2435
|
-
writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
|
|
2436
|
-
|
|
2437
|
-
const loaded = loadApiGuidesFromDir(dir, notify);
|
|
2438
|
-
expect(Object.keys(loaded.guides)).toEqual([]);
|
|
2439
|
-
expect(loaded.malformed).toHaveLength(1);
|
|
2440
|
-
// The per-guide warning goes through notify, not console.warn.
|
|
2441
|
-
expect(notify).toHaveBeenCalled();
|
|
2442
|
-
expect(warn).not.toHaveBeenCalled();
|
|
2443
|
-
const msgs = notify.mock.calls.map((c) => String(c[0])).join("\n");
|
|
2444
|
-
expect(msgs).toContain("Malformed guide");
|
|
2445
|
-
expect(msgs).toContain("boe.es");
|
|
2446
|
-
// Every notify call uses the warning kind (ctx.ui.notify signature).
|
|
2447
|
-
expect(notify.mock.calls.every((c) => c[1] === "warning")).toBe(true);
|
|
2448
|
-
} finally {
|
|
2449
|
-
warn.mockRestore();
|
|
2450
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2451
|
-
}
|
|
2452
|
-
});
|
|
2453
|
-
|
|
2454
|
-
it("a divergent + convergent pair sharing shortName loads only the convergent one", () => {
|
|
2455
|
-
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
2456
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
2457
|
-
try {
|
|
2458
|
-
// Old divergent folder (pre-migration) + new convergent folder
|
|
2459
|
-
// (slug). Only the convergent one loads.
|
|
2460
|
-
mkdirSync(join(dir, "boe.es"), { recursive: true });
|
|
2461
|
-
writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
|
|
2462
|
-
mkdirSync(join(dir, "boe"), { recursive: true });
|
|
2463
|
-
writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
|
|
2464
|
-
|
|
2465
|
-
const loaded = loadApiGuidesFromDir(dir);
|
|
2466
|
-
expect(Object.keys(loaded.guides)).toEqual(["boe"]);
|
|
2467
|
-
expect(loaded.malformed).toHaveLength(1);
|
|
2468
|
-
} finally {
|
|
2469
|
-
warn.mockRestore();
|
|
2470
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2471
|
-
}
|
|
2472
|
-
});
|
|
2473
|
-
|
|
2474
|
-
it("routes an empty/all-symbol shortName to malformed without throwing", () => {
|
|
2475
|
-
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
2476
|
-
try {
|
|
2477
|
-
for (const bad of ["'!!!'", "''"]) {
|
|
2478
|
-
mkdirSync(join(dir, "bad"), { recursive: true });
|
|
2479
|
-
writeFileSync(
|
|
2480
|
-
join(dir, "bad", "guide.md"),
|
|
2481
|
-
BOE_RECIPE.replace("shortName: BOE", `shortName: ${bad}`),
|
|
2482
|
-
);
|
|
2483
|
-
|
|
2484
|
-
const loaded = loadApiGuidesFromDir(dir);
|
|
2485
|
-
expect(Object.keys(loaded.guides)).toEqual([]);
|
|
2486
|
-
expect(loaded.malformed).toHaveLength(1);
|
|
2487
|
-
expect(loaded.malformed[0]!.filename).toBe("bad");
|
|
2488
|
-
expect(loaded.malformed[0]!.error.field).toBe("shortName");
|
|
2489
|
-
expect(loaded.malformed[0]!.error.fix).toContain("shortName");
|
|
2490
|
-
rmSync(join(dir, "bad"), { recursive: true, force: true });
|
|
2491
|
-
}
|
|
2492
|
-
} finally {
|
|
2493
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2494
|
-
}
|
|
2495
|
-
});
|
|
2496
|
-
|
|
2497
|
-
it("a divergent guide loads after an agent-assisted rename to slug(shortName)", () => {
|
|
2498
|
-
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
2499
|
-
try {
|
|
2500
|
-
// BOE_RECIPE has shortName: BOE → slug "boe"; folder is "boe.es" —
|
|
2501
|
-
// the pre-migration state routes to malformed.
|
|
2502
|
-
mkdirSync(join(dir, "boe.es"), { recursive: true });
|
|
2503
|
-
writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
|
|
2504
|
-
let loaded = loadApiGuidesFromDir(dir);
|
|
2505
|
-
expect(Object.keys(loaded.guides)).toEqual([]);
|
|
2506
|
-
expect(loaded.malformed).toHaveLength(1);
|
|
2507
|
-
|
|
2508
|
-
// The migration instruction (mv boe.es boe); /reload lives in the banner.
|
|
2509
|
-
renameSync(join(dir, "boe.es"), join(dir, "boe"));
|
|
2510
|
-
loaded = loadApiGuidesFromDir(dir);
|
|
2511
|
-
expect(Object.keys(loaded.guides)).toEqual(["boe"]);
|
|
2512
|
-
expect(loaded.malformed).toEqual([]);
|
|
2513
|
-
} finally {
|
|
2514
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2515
|
-
}
|
|
2516
|
-
});
|
|
2517
|
-
});
|
|
2518
|
-
|
|
2519
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2520
|
-
// stampFrontmatterField — save-stamp blank-line separation (G3)
|
|
2521
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2522
|
-
|
|
2523
|
-
describe("stampFrontmatterField", () => {
|
|
2524
|
-
it("inserts a blank line before a new key when the preceding line is non-empty", () => {
|
|
2525
|
-
const out = stampFrontmatterField(
|
|
2526
|
-
"---\nfoo: bar\n---\n",
|
|
2527
|
-
"schemaVersion",
|
|
2528
|
-
"0",
|
|
2529
|
-
);
|
|
2530
|
-
expect(out).toBe("---\nfoo: bar\n\nschemaVersion: 0\n---\n");
|
|
2531
|
-
});
|
|
2532
|
-
|
|
2533
|
-
it("does not double-blank when the preceding line is already empty", () => {
|
|
2534
|
-
const out = stampFrontmatterField(
|
|
2535
|
-
"---\nfoo: bar\n\n---\n",
|
|
2536
|
-
"schemaVersion",
|
|
2537
|
-
"0",
|
|
2538
|
-
);
|
|
2539
|
-
expect(out).toBe("---\nfoo: bar\n\nschemaVersion: 0\n---\n");
|
|
2540
|
-
});
|
|
2541
|
-
|
|
2542
|
-
it("replaces an existing key without introducing a blank line (idempotent re-stamp)", () => {
|
|
2543
|
-
const out = stampFrontmatterField(
|
|
2544
|
-
"---\nfoo: bar\nschemaVersion: 0\n---\n",
|
|
2545
|
-
"schemaVersion",
|
|
2546
|
-
"1",
|
|
2547
|
-
);
|
|
2548
|
-
expect(out).toBe("---\nfoo: bar\nschemaVersion: 1\n---\n");
|
|
2549
|
-
});
|
|
2550
|
-
|
|
2551
|
-
it("replaces a valueless key line instead of duplicating it", () => {
|
|
2552
|
-
// A bare `schemaVersion:` would otherwise survive the replace and
|
|
2553
|
-
// collide with the appended stamp (duplicate YAML key).
|
|
2554
|
-
const out = stampFrontmatterField(
|
|
2555
|
-
"---\nfoo: bar\nschemaVersion:\n---\n",
|
|
2556
|
-
"schemaVersion",
|
|
2557
|
-
"1",
|
|
2558
|
-
);
|
|
2559
|
-
expect(out).toBe("---\nfoo: bar\nschemaVersion: 1\n---\n");
|
|
2560
|
-
});
|
|
2561
|
-
});
|
|
2562
|
-
|
|
2563
2158
|
// ═══════════════════════════════════════════════════════════════════
|
|
2564
2159
|
// secretPathRefs — token-in-path secret injection
|
|
2565
2160
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
|
7
7
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
+
import { mockCtx, notifyText } from "./test-utils.js";
|
|
10
11
|
import { handleSecretsSubcommand } from "../core/secrets-command.js";
|
|
11
12
|
import {
|
|
12
13
|
listDomains,
|
|
@@ -28,28 +29,11 @@ afterEach(() => {
|
|
|
28
29
|
rmSync(dir, { recursive: true, force: true });
|
|
29
30
|
});
|
|
30
31
|
|
|
31
|
-
function mockCtx(overrides: Record<string, unknown> = {}): any {
|
|
32
|
-
return {
|
|
33
|
-
hasUI: true,
|
|
34
|
-
ui: {
|
|
35
|
-
input: vi.fn(async () => undefined),
|
|
36
|
-
confirm: vi.fn(async () => true),
|
|
37
|
-
notify: vi.fn(),
|
|
38
|
-
},
|
|
39
|
-
...overrides,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Collect notify() calls as a single string. */
|
|
44
|
-
function notified(ctx: any): string {
|
|
45
|
-
return ctx.ui.notify.mock.calls.map((c: any[]) => c[0]).join("\n");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
32
|
describe("/api secrets — list", () => {
|
|
49
33
|
it("reports (no secrets stored) with a --help hint when empty (instructions moved to --help)", async () => {
|
|
50
34
|
const ctx = mockCtx();
|
|
51
35
|
await handleSecretsSubcommand("", ctx);
|
|
52
|
-
const text =
|
|
36
|
+
const text = notifyText(ctx);
|
|
53
37
|
expect(text).toContain("(no secrets stored)");
|
|
54
38
|
expect(text).toContain("--help");
|
|
55
39
|
// The full instructions block no longer clutters the bare list.
|
|
@@ -62,7 +46,7 @@ describe("/api secrets — list", () => {
|
|
|
62
46
|
|
|
63
47
|
const ctx = mockCtx();
|
|
64
48
|
await handleSecretsSubcommand("", ctx);
|
|
65
|
-
const text =
|
|
49
|
+
const text = notifyText(ctx);
|
|
66
50
|
expect(text).toContain("d.example");
|
|
67
51
|
expect(text).toContain("api_key");
|
|
68
52
|
expect(text).not.toContain("super-secret-value");
|
|
@@ -75,7 +59,7 @@ describe("/api secrets --help", () => {
|
|
|
75
59
|
it("prints usage + full file-write instructions", async () => {
|
|
76
60
|
const ctx = mockCtx();
|
|
77
61
|
await handleSecretsSubcommand("--help", ctx);
|
|
78
|
-
const text =
|
|
62
|
+
const text = notifyText(ctx);
|
|
79
63
|
expect(text).toContain("Usage: /api secrets");
|
|
80
64
|
expect(text).toContain("/<domain>.json");
|
|
81
65
|
});
|
|
@@ -83,7 +67,7 @@ describe("/api secrets --help", () => {
|
|
|
83
67
|
it("accepts the bare 'help' alias", async () => {
|
|
84
68
|
const ctx = mockCtx();
|
|
85
69
|
await handleSecretsSubcommand("help", ctx);
|
|
86
|
-
expect(
|
|
70
|
+
expect(notifyText(ctx)).toContain("Usage: /api secrets");
|
|
87
71
|
});
|
|
88
72
|
});
|
|
89
73
|
|
|
@@ -98,8 +82,8 @@ describe("/api secrets <domain> <name> — manual entry", () => {
|
|
|
98
82
|
expect(promptTitle).toContain("d.example");
|
|
99
83
|
expect(promptTitle).toContain("api_key");
|
|
100
84
|
expect(readSecret("d.example", "api_key")).toBe("demo-key-abc");
|
|
101
|
-
expect(
|
|
102
|
-
expect(
|
|
85
|
+
expect(notifyText(ctx)).toContain("Stored secret 'api_key'");
|
|
86
|
+
expect(notifyText(ctx)).not.toContain("demo-key-abc");
|
|
103
87
|
});
|
|
104
88
|
|
|
105
89
|
it("aborts on cancel (undefined) without writing", async () => {
|
|
@@ -113,7 +97,7 @@ describe("/api secrets <domain> <name> — manual entry", () => {
|
|
|
113
97
|
const ctx = mockCtx();
|
|
114
98
|
ctx.ui.input.mockResolvedValueOnce(" ");
|
|
115
99
|
await handleSecretsSubcommand("d.example k", ctx);
|
|
116
|
-
expect(
|
|
100
|
+
expect(notifyText(ctx)).toContain("Aborted");
|
|
117
101
|
expect(listNames("d.example")).toEqual([]);
|
|
118
102
|
});
|
|
119
103
|
|
|
@@ -121,7 +105,7 @@ describe("/api secrets <domain> <name> — manual entry", () => {
|
|
|
121
105
|
const ctx = mockCtx({ hasUI: false });
|
|
122
106
|
await handleSecretsSubcommand("d.example k", ctx);
|
|
123
107
|
expect(ctx.ui.input).not.toHaveBeenCalled();
|
|
124
|
-
expect(
|
|
108
|
+
expect(notifyText(ctx)).toContain("d.example.json");
|
|
125
109
|
});
|
|
126
110
|
});
|
|
127
111
|
|
|
@@ -135,7 +119,7 @@ describe("/api secrets <domain> — assisted entry", () => {
|
|
|
135
119
|
await handleSecretsSubcommand("d.example", ctx);
|
|
136
120
|
|
|
137
121
|
expect(readSecret("d.example", "api_key")).toBe("demo-key-xyz");
|
|
138
|
-
const text =
|
|
122
|
+
const text = notifyText(ctx);
|
|
139
123
|
expect(text).toContain("Secrets for 'd.example'");
|
|
140
124
|
expect(text).not.toContain("demo-key-xyz");
|
|
141
125
|
});
|
|
@@ -146,7 +130,7 @@ describe("/api secrets <domain> — assisted entry", () => {
|
|
|
146
130
|
const ctx = mockCtx({ hasUI: false });
|
|
147
131
|
await handleSecretsSubcommand("d.example", ctx);
|
|
148
132
|
expect(ctx.ui.input).not.toHaveBeenCalled();
|
|
149
|
-
const text =
|
|
133
|
+
const text = notifyText(ctx);
|
|
150
134
|
expect(text).toContain("k");
|
|
151
135
|
expect(text).not.toContain("secret-42");
|
|
152
136
|
});
|
|
@@ -188,7 +172,7 @@ body
|
|
|
188
172
|
expect(prompt).toContain("d.example");
|
|
189
173
|
expect(prompt).toContain("api_key");
|
|
190
174
|
expect(readSecret("d.example", "api_key")).toBe("demo-key-abc");
|
|
191
|
-
const text =
|
|
175
|
+
const text = notifyText(ctx);
|
|
192
176
|
expect(text).toContain("Declared (guide): api_key");
|
|
193
177
|
expect(text).not.toContain("demo-key-abc");
|
|
194
178
|
} finally {
|
|
@@ -248,7 +232,7 @@ describe("/api secrets <domain> <name> --delete", () => {
|
|
|
248
232
|
|
|
249
233
|
expect(ctx.ui.confirm).not.toHaveBeenCalled();
|
|
250
234
|
expect(readSecret("d.example", "k")).toBeNull();
|
|
251
|
-
const text =
|
|
235
|
+
const text = notifyText(ctx);
|
|
252
236
|
expect(text).toContain("Deleted secret 'k'");
|
|
253
237
|
expect(text).not.toContain("secret-42");
|
|
254
238
|
});
|
|
@@ -259,7 +243,7 @@ describe("/api secrets <domain> <name> --delete", () => {
|
|
|
259
243
|
const ctx = mockCtx();
|
|
260
244
|
await handleSecretsSubcommand("d.example nope --delete", ctx);
|
|
261
245
|
expect(readSecret("d.example", "keep")).toBe("1");
|
|
262
|
-
expect(
|
|
246
|
+
expect(notifyText(ctx)).toContain("No secret 'nope'");
|
|
263
247
|
});
|
|
264
248
|
|
|
265
249
|
it("prunes the domain file after deleting the last secret", async () => {
|
|
@@ -289,7 +273,7 @@ describe("/api secrets <domain> --delete", () => {
|
|
|
289
273
|
expect(ctx.ui.confirm).toHaveBeenCalledTimes(1);
|
|
290
274
|
expect(readSecret("d.example", "a")).toBeNull();
|
|
291
275
|
expect(readSecret("d.example", "b")).toBeNull();
|
|
292
|
-
expect(
|
|
276
|
+
expect(notifyText(ctx)).toContain("Deleted all secrets for 'd.example'.");
|
|
293
277
|
});
|
|
294
278
|
|
|
295
279
|
it("aborts (nothing deleted) when the user declines the confirm", async () => {
|
|
@@ -300,7 +284,7 @@ describe("/api secrets <domain> --delete", () => {
|
|
|
300
284
|
|
|
301
285
|
await handleSecretsSubcommand("d.example --delete", ctx);
|
|
302
286
|
expect(readSecret("d.example", "a")).toBe("1");
|
|
303
|
-
expect(
|
|
287
|
+
expect(notifyText(ctx)).toContain("Cancelled");
|
|
304
288
|
});
|
|
305
289
|
|
|
306
290
|
it("headless deletes all without prompting or hanging", async () => {
|
|
@@ -314,7 +298,7 @@ describe("/api secrets <domain> --delete", () => {
|
|
|
314
298
|
it("reports when a domain has no secrets", async () => {
|
|
315
299
|
const ctx = mockCtx();
|
|
316
300
|
await handleSecretsSubcommand("empty.example --delete", ctx);
|
|
317
|
-
expect(
|
|
301
|
+
expect(notifyText(ctx)).toContain("No secrets stored");
|
|
318
302
|
});
|
|
319
303
|
});
|
|
320
304
|
|
|
@@ -322,12 +306,12 @@ describe("/api secrets --delete misuse", () => {
|
|
|
322
306
|
it("bare --delete without a domain is a usage warning", async () => {
|
|
323
307
|
const ctx = mockCtx();
|
|
324
308
|
await handleSecretsSubcommand("--delete", ctx);
|
|
325
|
-
expect(
|
|
309
|
+
expect(notifyText(ctx)).toContain("Usage");
|
|
326
310
|
});
|
|
327
311
|
|
|
328
312
|
it("--help wins over --delete", async () => {
|
|
329
313
|
const ctx = mockCtx();
|
|
330
314
|
await handleSecretsSubcommand("--delete --help", ctx);
|
|
331
|
-
expect(
|
|
315
|
+
expect(notifyText(ctx)).toContain("Usage: /api secrets");
|
|
332
316
|
});
|
|
333
317
|
});
|