kaloko 0.21.3 → 0.22.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/package.json +1 -1
- package/schemas/run.v1.json +5 -0
- package/schemas/scenario.v1.json +1 -0
- package/skills/kaloko/SKILL.md +1 -0
- package/skills/kaloko/reference/acceptance.md +9 -1
- package/skills/kaloko/reference/collaboration.md +4 -1
- package/skills/kaloko/reference/design.md +6 -1
- package/skills/kaloko/reference/devices.md +10 -0
- package/skills/kaloko/reference/docs.md +5 -0
- package/skills/kaloko/reference/environments.md +3 -1
- package/src/cli.mjs +54 -13
- package/src/lib/access.mjs +109 -16
- package/src/lib/browser-daemon.mjs +4 -1
- package/src/lib/build.mjs +2 -0
- package/src/lib/changelog.mjs +46 -4
- package/src/lib/cli-args.mjs +1 -1
- package/src/lib/design-cli.mjs +1 -1
- package/src/lib/dimensions.mjs +16 -2
- package/src/lib/draft.mjs +2 -1
- package/src/lib/gate-cdp.mjs +120 -0
- package/src/lib/health-catalogue.mjs +122 -0
- package/src/lib/init-detect.mjs +2 -1
- package/src/lib/linkcheck.mjs +12 -3
- package/src/lib/pack-help.mjs +17 -0
- package/src/lib/packs.mjs +27 -0
- package/src/lib/stack-core.mjs +9 -0
- package/src/lib/stack.mjs +2 -2
- package/templates/ACCEPTANCE.md +1 -1
- package/templates/cs/ACCEPTANCE.md +1 -1
- package/templates/scenario.yml +2 -2
- package/viewer/index.html +185 -62
package/package.json
CHANGED
package/schemas/run.v1.json
CHANGED
|
@@ -442,6 +442,11 @@
|
|
|
442
442
|
],
|
|
443
443
|
"description": "Version of the application under test (git tag on HEAD or --app-version); documentation captures carry it."
|
|
444
444
|
},
|
|
445
|
+
"packs_off": {
|
|
446
|
+
"type": "object",
|
|
447
|
+
"description": "Packs the steps list but this run left out, and why: design (a code-only pack on a design environment that the scenario did not keep with health.design_packs).",
|
|
448
|
+
"additionalProperties": { "enum": ["design"] }
|
|
449
|
+
},
|
|
445
450
|
"site": {
|
|
446
451
|
"type": "object",
|
|
447
452
|
"description": "Site checks (#152, scenario health.site), once per site and run: robots.txt, sitemaps, llms.txt, http→https and www redirects. Advisory: health shows a finding per failed check (pack site, step site:<host>); they never decide a verdict.",
|
package/schemas/scenario.v1.json
CHANGED
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
"description": "Health (CS Kondice): the checks of packs are advisory, shown apart from acceptance and never part of a verdict. acceptance lists the packs whose checks still count for acceptance in this scenario, as they did before health was split off (the design pack always does). To make one check count, adopt it: kaloko health adopt <step>:<check> (e.g. home:HC103) adds the criterion AC103 with from: HC103.",
|
|
96
96
|
"properties": {
|
|
97
97
|
"acceptance": { "type": "array", "uniqueItems": true, "items": { "enum": ["seo", "usability", "a11y", "perf", "console", "errors", "app", "design", "aeo", "locale"] } },
|
|
98
|
+
"design_packs": { "type": "array", "uniqueItems": true, "items": { "enum": ["seo", "aeo", "perf", "console", "errors"] }, "description": "Code-only packs (seo, aeo, perf, console, errors) are off when the scenario is walked on a design environment, because a designer's local HTML has no real meta tags, server or error statuses. List the ones that should still run on design versions. a11y, usability and locale always run." },
|
|
98
99
|
"site": {
|
|
99
100
|
"description": "Site checks (#152), once per site and run: robots.txt, the sitemaps it lists, llms.txt, and http→https and www redirects in one hop. Advisory (health, pack site, HC11xx); off unless set, because they make requests of their own. true for the defaults, or an object.",
|
|
100
101
|
"oneOf": [
|
package/skills/kaloko/SKILL.md
CHANGED
|
@@ -107,6 +107,7 @@ design reference or baseline, merging a design branch, keeping your version of
|
|
|
107
107
|
a step in a sync conflict (`--mine`), turning on `html_view`, turning on recordings for
|
|
108
108
|
a whole project (`capture.record` in the config: storage), a real clean-up
|
|
109
109
|
(`kaloko prune --yes`), trashing someone else's run, archiving a scenario,
|
|
110
|
+
moving a scenario to another project (`kaloko scenario move`),
|
|
110
111
|
renaming a project or restricting it (`kaloko projects rename`, a `kaloko
|
|
111
112
|
projects sync` that would rename), picking one site of a repository that
|
|
112
113
|
holds several (ask which one before `kaloko init` or a walk),
|
|
@@ -62,6 +62,14 @@ criterion with the same id wins and counts for acceptance (that is what
|
|
|
62
62
|
`--coverage`; the plan lists only what the brief asks for. Use packs for
|
|
63
63
|
public pages and landings; task flows keep their own criteria.
|
|
64
64
|
|
|
65
|
+
On a design environment (`kind: design`) the code-only packs `seo`, `aeo`,
|
|
66
|
+
`perf`, `console` and `errors` are off: the designer's HTML has no real server,
|
|
67
|
+
meta tags or error statuses. `health: { design_packs: [console] }` on the
|
|
68
|
+
scenario keeps one on there; `a11y`, `usability` and `locale` always run. The
|
|
69
|
+
run manifest names what was turned off in `packs_off`. Every check of every
|
|
70
|
+
pack, with why it matters and its severity, is in the help page Health checks
|
|
71
|
+
(`reference/health-checks` of Kaloko's docs).
|
|
72
|
+
|
|
65
73
|
Set `page` on every step (or once on the scenario) so the usability pack
|
|
66
74
|
asks the right questions:
|
|
67
75
|
|
|
@@ -215,7 +223,7 @@ When the work has a repository and an issue number, make them visible:
|
|
|
215
223
|
|
|
216
224
|
- scenario `id` carries the ticket (`TASK-1273-club-activation`, `WEB-12-checkout`); the CLI derives the issue number from it or from the branch (`feat/1273-…`) and links it to the repository's origin (`acme/shop#1273`);
|
|
217
225
|
- set `issue: { number, repo?, url? }` explicitly when the id or branch cannot carry it (for example a Plane item);
|
|
218
|
-
- the project is the repository name unless `project:` in `kaloko.config.yml` says otherwise; a single flow may sort itself elsewhere with `project: <slug>` in its scenario (optional). Projects are records of the organization: `kaloko projects` or MCP `list_projects` show the slugs to use; admins create them in Settings → Projects
|
|
226
|
+
- the project is the repository name unless `project:` in `kaloko.config.yml` says otherwise; a single flow may sort itself elsewhere with `project: <slug>` in its scenario (optional). Projects are records of the organization: `kaloko projects` or MCP `list_projects` show the slugs to use; admins create them in Settings → Projects and move a scenario on its page in the app (Scenario settings), people with the Developer / Tester role with `kaloko scenario move <id> --project <slug>`; a move wins over the scenario file and the config;
|
|
219
227
|
- the canvas header, the organization overview and `kaloko feedback` show `repo#number · title`, so name the scenario `title` for humans, not for the ticket;
|
|
220
228
|
- requirement IDs from the team's tracker or QMS go into `refs:` on the scenario, a step or a criterion (`refs: [URS-12, FS-3.4]`, free text, never invented); they flow into the acceptance record and the trace matrix ([evidence.md](evidence.md)).
|
|
221
229
|
|
|
@@ -231,13 +231,16 @@ npx kaloko prune --scenario TASK-1273-cart --keep 3 # dry run: lists
|
|
|
231
231
|
npx kaloko prune --scenario TASK-1273-cart --keep 3 --yes # ask first
|
|
232
232
|
npx kaloko keep <run-id> # never expires, never pruned (--off to undo)
|
|
233
233
|
npx kaloko archive TASK-1273-klub # retired scenario, ask first (admin); --undo
|
|
234
|
+
npx kaloko scenario move TASK-1273-klub --project eco-web # ask first: runs and drafts follow
|
|
234
235
|
```
|
|
235
236
|
|
|
236
237
|
- **Without asking:** trash runs you uploaded in this session that were
|
|
237
238
|
superseded before anyone looked at them (debug rounds without comments or a
|
|
238
239
|
verdict), and a dry-run `prune`.
|
|
239
240
|
- **Ask first:** `prune --yes`, trashing someone else's run, archiving a
|
|
240
|
-
scenario
|
|
241
|
+
scenario, moving a scenario to another project (`kaloko scenario move`: its
|
|
242
|
+
runs and drafts follow, people who see only the old project lose them, and
|
|
243
|
+
the move wins over `project:` in the config for later shares).
|
|
241
244
|
- **Never:** trash an accepted run, a baseline, a run with review comments, or a
|
|
242
245
|
run linked from an open PR or issue comment. `prune` already keeps kept,
|
|
243
246
|
accepted, baseline, design-reference and commented runs, and without the admin
|
|
@@ -18,7 +18,12 @@ environments:
|
|
|
18
18
|
`kaloko start --scenario qa/flows/checkout.yml --env design` starts a run of it. The scenario is the one the
|
|
19
19
|
implementation uses: same step ids, same criteria. Design steps meet the acceptance criteria too (headings,
|
|
20
20
|
labels, semantic questions), so a design is accepted before anyone builds it. Checks against the design
|
|
21
|
-
reference (`design_pixels`, `design_structure`) are left out on a design environment
|
|
21
|
+
reference (`design_pixels`, `design_structure`) are left out on a design environment, and so are the code-only
|
|
22
|
+
packs `seo`, `aeo`, `perf`, `console` and `errors` (a local folder has no real server, meta tags or error
|
|
23
|
+
statuses). `health: { design_packs: [console] }` on the scenario keeps one on; `a11y`, `usability` and `locale`
|
|
24
|
+
run on designs too. Kaloko writes no code from a design: Live runs the designer's own files, and Dev mode is the
|
|
25
|
+
handoff (measurements, tokens, states, a prompt to copy for an agent) that developers build from in their code.
|
|
26
|
+
Published versions are listed under Design drafts in the app, not among the runs.
|
|
22
27
|
|
|
23
28
|
## The bundle
|
|
24
29
|
|
|
@@ -28,6 +28,16 @@ npx kaloko browser install --browsers webkit,firefox # once per machine or CI
|
|
|
28
28
|
npx kaloko devices # the preset catalogue
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
## Default capture setup
|
|
32
|
+
|
|
33
|
+
When the person names no sizes, a new scenario uses `viewports: { desktop: [1280, 800], mobile: [360, 800] }`
|
|
34
|
+
(what `kaloko init`, `kaloko draft` and the template write) and captures whole pages (`full_page` is on unless a
|
|
35
|
+
step or stack turns it off). Add a tablet only when the task is about one, as `tablet: [744, 1133]`; a width of
|
|
36
|
+
900 or more is a small desktop, not a tablet. Do not pick sizes per step to fit a small element, and leave
|
|
37
|
+
`capture.annotate` off unless the task is about marked areas of the page. Never change the `viewports:` of an
|
|
38
|
+
existing scenario to these defaults: its baselines were taken at its own sizes (a scenario without `viewports:`
|
|
39
|
+
still captures at 1280 × 800 and 390 × 844).
|
|
40
|
+
|
|
31
41
|
Every combination is one capture: `checkout × 3 viewports × 2 browsers × 2 schemes` is 12 screenshots per step and
|
|
32
42
|
locale. Keep the matrix to what the task is about. A Safari bug report needs `browsers: [chromium, webkit]` on the
|
|
33
43
|
affected steps, not every dimension on every step. A run refuses more than 400 combinations.
|
|
@@ -268,6 +268,11 @@ A product shows in the app (Products in the main menu, `/<org>/products`) once i
|
|
|
268
268
|
their newest version and docs, and names those of the config that are not there yet. After adding or renaming a
|
|
269
269
|
product in the config, run `kaloko products sync` and say so in your summary.
|
|
270
270
|
|
|
271
|
+
A product that is no longer shipped is archived with `kaloko products remove <product>` (an admin of its projects):
|
|
272
|
+
it leaves Products, its changelog and docs answer 404, its releases and docs are kept. `products sync` then reports
|
|
273
|
+
it as archived instead of creating it again; delete it from `products:` too. Archive only when the person asks;
|
|
274
|
+
`kaloko products restore <product>` undoes it.
|
|
275
|
+
|
|
271
276
|
## Readers
|
|
272
277
|
|
|
273
278
|
The changelog page (`/<org>/products/<product>/changelog`, or `/<org>/projects/<project>/changelog`) and the docs
|
|
@@ -78,7 +78,9 @@ Rules:
|
|
|
78
78
|
Choose names that fit the project; ask the person for the values.
|
|
79
79
|
- The gate is sent only to `access.origins`, never to third parties (analytics,
|
|
80
80
|
CDNs, payment providers). List an API on another host explicitly;
|
|
81
|
-
`https://*.example.com` covers subdomains.
|
|
81
|
+
`https://*.example.com` covers subdomains. Every redirect hop is checked on
|
|
82
|
+
its own: one within the gate keeps it, one to another host (audio on S3, a
|
|
83
|
+
sign-in at an identity provider) is followed without it.
|
|
82
84
|
- The CLI applies the gate and the readonly policy to every browser context it
|
|
83
85
|
creates: the shared browser, `walk` sessions and one-off captures. A context
|
|
84
86
|
you create yourself over CDP does not get them; work in the shared browser's
|
package/src/cli.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral
|
|
|
14
14
|
import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
|
|
15
15
|
import { observeConsole, pageTarget } from './lib/console.mjs'
|
|
16
16
|
import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
|
|
17
|
-
import { newContext, accountFor,
|
|
17
|
+
import { newContext, accountFor, gateForRequests, basicCredentials, gateRouting, continueThroughGate } from './lib/access.mjs'
|
|
18
18
|
import { configureGate, disposeShared } from './lib/linkcheck.mjs'
|
|
19
19
|
import { rememberEnvironment } from './lib/secrets.mjs'
|
|
20
20
|
import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
|
|
@@ -33,10 +33,10 @@ import { detectProject, initScenario, skeletonScenario, scenarioHeader, formatDe
|
|
|
33
33
|
import { checkSlug, checkName, setConfigKeys, createProject, renameProject, syncProject, shareProjectNote } from './lib/projects-cli.mjs'
|
|
34
34
|
import { recheckCaptures } from './lib/recheck.mjs'
|
|
35
35
|
import { runFindings, findingsText } from './lib/findings.mjs'
|
|
36
|
-
import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, sourceLocaleOf, stackReader, readsOutside } from './lib/stack.mjs'
|
|
36
|
+
import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, rotationDate, sourceLocaleOf, stackReader, readsOutside } from './lib/stack.mjs'
|
|
37
37
|
import { walkSiteChecks, siteChecksOn } from './lib/site.mjs'
|
|
38
38
|
import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
|
|
39
|
-
import { expandPacks } from './lib/packs.mjs'
|
|
39
|
+
import { expandPacks, forEnvironment } from './lib/packs.mjs'
|
|
40
40
|
import { upsertPrComment } from './lib/pr.mjs'
|
|
41
41
|
import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
|
|
42
42
|
import { healthCmd } from './lib/health.mjs'
|
|
@@ -304,6 +304,12 @@ Results
|
|
|
304
304
|
keep <run-id> [--off] A kept run never expires and is never pruned
|
|
305
305
|
archive <scenario-id> [--off] Hide a retired scenario from the scenario list (admin); its runs stay reachable;
|
|
306
306
|
--off shows it again
|
|
307
|
+
scenario move <scenario-id> --project <slug> [--json]
|
|
308
|
+
Move a scenario to another project with all its runs, design drafts and screens
|
|
309
|
+
(also on kaloko.app: the scenario’s page, Scenario settings). The move wins over
|
|
310
|
+
project: in the config and the scenario file, so later shares of the scenario land in
|
|
311
|
+
the new project too. Needs the Developer / Tester role in both projects (their
|
|
312
|
+
admin for a restricted one); the project must exist (kaloko projects)
|
|
307
313
|
inbox [--all] [--read] [--json] Your notifications on kaloko.app (mentions, returned runs, new runs, expiry); --read marks them read
|
|
308
314
|
calibrate [--golden labels.yml] [--scenario id [--limit 30]] [--target 0.95] [--json]
|
|
309
315
|
Semantic evaluator vs human labels: precision/recall per criterion at the
|
|
@@ -385,6 +391,12 @@ Products (products: in kaloko.config.yml; without it the project is the product)
|
|
|
385
391
|
publish: is applied only for an admin of the product's projects
|
|
386
392
|
products list [--json] The products on Kaloko you read: newest release, docs, who reads them, and
|
|
387
393
|
the products of the config that are not on Kaloko yet
|
|
394
|
+
products remove <product> [--json]
|
|
395
|
+
Archive a product: it leaves Products, its changelog and docs pages answer 404
|
|
396
|
+
and products sync does not create it again. Releases and docs are kept
|
|
397
|
+
(an admin of the product's projects)
|
|
398
|
+
products restore <product> [--json]
|
|
399
|
+
Bring an archived product back with its releases and docs
|
|
388
400
|
|
|
389
401
|
Changelog (Keep a Changelog in CHANGELOG.md; pictures are step references kaloko:<scenario>/<step>)
|
|
390
402
|
changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]
|
|
@@ -579,8 +591,8 @@ async function runContext (args) {
|
|
|
579
591
|
const run = await resolveRun({ root: ctx.root, output: ctx.config.output, run: args.run })
|
|
580
592
|
const scenario = await loadScenario(resolve(ctx.root, run.meta.scenario_file))
|
|
581
593
|
const env = environment(ctx.config, run.meta.environment.name)
|
|
582
|
-
// a design is not checked against itself
|
|
583
|
-
|
|
594
|
+
// a design is not checked against itself (#42), and code-only packs are off on a design environment (#205)
|
|
595
|
+
forEnvironment(scenario, env)
|
|
584
596
|
rememberEnvironment(env)
|
|
585
597
|
return { ...ctx, run, scenario, env }
|
|
586
598
|
}
|
|
@@ -749,14 +761,15 @@ async function captureMail ({ config, root, run, env, scenario = null, node, loc
|
|
|
749
761
|
// context keeps it on; the e-mail's own scripts are stripped and every
|
|
750
762
|
// non-image request is blocked instead. Images hosted behind the environment's
|
|
751
763
|
// gate get its headers, nobody else does.
|
|
752
|
-
const
|
|
753
|
-
const
|
|
754
|
-
const
|
|
764
|
+
const credentials = basicCredentials(env)
|
|
765
|
+
const page = await (await browser.newContext(credentials ? { httpCredentials: credentials } : {})).newPage()
|
|
766
|
+
const { headers: gate, toGate } = gateRouting(env)
|
|
755
767
|
await page.route('**/*', (route) => {
|
|
756
768
|
const r = route.request()
|
|
757
769
|
if (r.url().startsWith('data:')) return route.continue()
|
|
758
770
|
if (r.resourceType() !== 'image') return route.abort()
|
|
759
|
-
|
|
771
|
+
// an image that redirects to a CDN does not take the gate along
|
|
772
|
+
return toGate(r.url()) ? continueThroughGate(route, gate, toGate) : route.continue()
|
|
760
773
|
})
|
|
761
774
|
const html = (message.html || `<pre>${(message.text || '').replace(/</g, '<')}</pre>`).replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
762
775
|
// Remote images are allowed for the screenshot but must not hold the capture hostage.
|
|
@@ -823,7 +836,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
823
836
|
// gate and the walk's cookies on its own origins only
|
|
824
837
|
const reader = readsOutside(node.items) ? await stackReader({ page, gate, origins: envOrigins(env, scenario, locale) }) : null
|
|
825
838
|
let resolved
|
|
826
|
-
try { resolved = await resolveItems({ node, base: baseUrl(env, locale), sourceBase: baseUrl(env, sourceLocale), page, scenarioDir, env, locale, scenario, limit: Number(args.limit) || undefined, rotation: { run: round }, checked: state?.checked || null, only: list(args.items), cache, readPage: reader?.read || null }) } finally { await reader?.dispose() }
|
|
839
|
+
try { resolved = await resolveItems({ node, base: baseUrl(env, locale), sourceBase: baseUrl(env, sourceLocale), page, scenarioDir, env, locale, scenario, limit: Number(args.limit) || undefined, rotation: { run: round, now: rotationDate(run.meta.started_at) }, checked: state?.checked || null, only: list(args.items), cache, readPage: reader?.read || null }) } finally { await reader?.dispose() }
|
|
827
840
|
const { items, total, truncated, all, rotation, partial, variants, resolve_failed: unresolved, resolve_error: unresolvedWhy } = resolved
|
|
828
841
|
if (unresolved) say(`! ${node.id} ${locale}${tag}: items.resolve failed for ${unresolved} page(s), walked without an edit link (${unresolvedWhy})`)
|
|
829
842
|
if (variants) {
|
|
@@ -1891,7 +1904,7 @@ const commands = {
|
|
|
1891
1904
|
await commands.start({ scenario: scenarioFile, env: envName(), locales: args.locales, viewports: args.viewports, executor: args.executor || `import:${source.tool}`, config: args.config, _: [] })
|
|
1892
1905
|
run = await resolveRun({ root, output: config.output })
|
|
1893
1906
|
} else say(`Importing into run ${run.meta.id} (${run.meta.scenario.id}); --adhoc or --scenario starts a new one`)
|
|
1894
|
-
const scenario = await loadScenario(resolve(root, run.meta.scenario_file))
|
|
1907
|
+
const scenario = forEnvironment(await loadScenario(resolve(root, run.meta.scenario_file)), run.meta.environment)
|
|
1895
1908
|
const map = Object.fromEntries((list(args.map) || []).map((p) => p.split('=')).filter((p) => p.length === 2 && p[0] && p[1]).map(([a, b]) => [a.trim(), b.trim()]))
|
|
1896
1909
|
const locale = typeof args.locale === 'string' && run.meta.scope.locales.includes(args.locale) ? args.locale : run.meta.scope.locales[0]
|
|
1897
1910
|
const enc = await launchEphemeral(config).catch(() => null)
|
|
@@ -2612,6 +2625,27 @@ const commands = {
|
|
|
2612
2625
|
say(off ? `✓ ${args._[0]} is active again` : `✓ ${args._[0]} archived: hidden from the scenario list, its runs stay reachable (kaloko archive ${args._[0]} --off)`)
|
|
2613
2626
|
},
|
|
2614
2627
|
|
|
2628
|
+
/** kaloko scenario move: the scenario's project on Kaloko, its runs and drafts follow (POST …/scenarios/<id>/project). */
|
|
2629
|
+
async scenario (args) {
|
|
2630
|
+
const usage = 'Usage: kaloko scenario move <scenario-id> --project <slug> [--json]'
|
|
2631
|
+
const [sub, id, ...rest] = args._
|
|
2632
|
+
if (sub !== 'move') throw new UsageError(sub ? `kaloko scenario ${sub}: there is no such sub-command.\n${usage}` : usage)
|
|
2633
|
+
if (!id || rest.length) throw new UsageError(`kaloko scenario move needs one scenario id.\n${usage}`)
|
|
2634
|
+
if (args.project === undefined) throw new UsageError(`kaloko scenario move needs --project <slug>: the project it goes to (kaloko projects lists them).\n${usage}`)
|
|
2635
|
+
const slug = checkSlug(args.project === true ? '' : String(args.project), '--project slug')
|
|
2636
|
+
const { config } = await context(args)
|
|
2637
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
2638
|
+
const client = service(config)
|
|
2639
|
+
// only into a project that exists: the service would create a new one from a typo
|
|
2640
|
+
const projects = await client.request('GET', `/orgs/${config.org}/projects`)
|
|
2641
|
+
const target = projects.find((p) => p.slug === slug)
|
|
2642
|
+
if (!target) throw new UserError(`There is no project ${slug} that you see in ${config.org}. kaloko projects lists them; kaloko projects create ${slug} makes one.`)
|
|
2643
|
+
const r = await client.request('POST', `/orgs/${config.org}/scenarios/${encodeURIComponent(id)}/project`, { project: slug })
|
|
2644
|
+
if (args.json) { say(JSON.stringify(r, null, 2)); return }
|
|
2645
|
+
say(`✓ ${r.scenario} is in ${r.project} (${target.name}) now, with all its runs, design drafts and screens. Later shares of it land there too.`)
|
|
2646
|
+
if (config.project && config.project !== r.project) say(`Note: ${CONFIG_FILE} says project: ${config.project}; for ${r.scenario} the move wins. kaloko scenario move ${r.scenario} --project ${config.project} takes it back.`)
|
|
2647
|
+
},
|
|
2648
|
+
|
|
2615
2649
|
async login (args) {
|
|
2616
2650
|
// a project is optional: it only names the service URL, the organization and the token variable
|
|
2617
2651
|
let config = {}
|
|
@@ -2881,12 +2915,19 @@ const commands = {
|
|
|
2881
2915
|
if (!r.ok || (args.strict && r.warnings)) process.exitCode = 1
|
|
2882
2916
|
},
|
|
2883
2917
|
|
|
2884
|
-
/** `kaloko products sync|list`: the products of the config on Kaloko, without a release; what the service has. */
|
|
2918
|
+
/** `kaloko products sync|list|remove|restore`: the products of the config on Kaloko, without a release; what the service has. */
|
|
2885
2919
|
async products (args) {
|
|
2886
2920
|
const sub = args._[0]
|
|
2887
|
-
if (!['sync', 'list'].includes(sub)) throw new UsageError('Usage: kaloko products sync [--product p] [--dry-run] [--json]\n kaloko products list [--json]')
|
|
2921
|
+
if (!['sync', 'list', 'remove', 'restore'].includes(sub)) throw new UsageError('Usage: kaloko products sync [--product p] [--dry-run] [--json]\n kaloko products list [--json]\n kaloko products remove <product> [--json]\n kaloko products restore <product> [--json]')
|
|
2888
2922
|
const { config, root } = await context(args)
|
|
2889
2923
|
const lib = await import('./lib/changelog.mjs')
|
|
2924
|
+
if (sub === 'remove' || sub === 'restore') {
|
|
2925
|
+
if (!args._[1]) throw new UsageError(`Usage: kaloko products ${sub} <product> [--json]`)
|
|
2926
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
2927
|
+
const r = sub === 'remove' ? await lib.removeProduct({ config, client: service(config), slug: args._[1] }) : await lib.restoreProduct({ config, client: service(config), slug: args._[1] })
|
|
2928
|
+
say(args.json ? JSON.stringify(r, null, 2) : sub === 'remove' ? lib.formatRemove(r) : lib.formatRestore(r))
|
|
2929
|
+
return
|
|
2930
|
+
}
|
|
2890
2931
|
if (sub === 'list') {
|
|
2891
2932
|
if (!config.org) throw new UserError(NO_ORG)
|
|
2892
2933
|
const r = await lib.listProducts({ root, config, client: service(config) })
|
package/src/lib/access.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { join, resolve } from 'node:path'
|
|
|
11
11
|
import { policyViolation, baseOrigins } from './config.mjs'
|
|
12
12
|
import { secret, remember } from './secrets.mjs'
|
|
13
13
|
import { UserError } from './util.mjs'
|
|
14
|
+
import { gateHeadersPerHop } from './gate-cdp.mjs'
|
|
14
15
|
|
|
15
16
|
/** The environment's gate: `access`, with the older `http_auth` read as `access.basic`. */
|
|
16
17
|
export function gateOf (env) {
|
|
@@ -78,15 +79,107 @@ export function clientCertificates (env, root = process.cwd()) {
|
|
|
78
79
|
}))
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
/**
|
|
83
|
+
* HTTP Basic of the gate as Playwright `httpCredentials`, or null: only for a gate on one exact origin (Playwright
|
|
84
|
+
* scopes them to a single origin). The browser answers a 401 challenge of that origin with them and nowhere else.
|
|
85
|
+
*/
|
|
86
|
+
export function basicCredentials (env) {
|
|
87
|
+
const gate = gateOf(env); const origins = gateOrigins(env)
|
|
88
|
+
if (!gate.basic || origins.length !== 1 || String(origins[0]).includes('*')) return null
|
|
89
|
+
return { username: secret(gate.basic.user_env, `HTTP Basic user for ${env.name}`), password: secret(gate.basic.password_env, `HTTP Basic password for ${env.name}`), origin: new URL(origins[0]).origin }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How a browser context adds the gate: `headers` for requests to the gate's origins that `toGate` matches. A gate of
|
|
94
|
+
* HTTP Basic alone on one origin is left to the browser, which answers the challenge with httpCredentials (to that
|
|
95
|
+
* origin only, across redirects as well), so nothing is added for it; once there are headers to add (or several
|
|
96
|
+
* origins), Basic goes up front with them.
|
|
97
|
+
*/
|
|
98
|
+
export function gateRouting (env, vars = process.env) {
|
|
99
|
+
const headers = gateHeaders(env, vars)
|
|
100
|
+
const own = Object.keys(headers).filter((n) => !(n === 'Authorization' && basicCredentials(env)))
|
|
101
|
+
return { headers, toGate: own.length ? originMatcher(gateOrigins(env)) : NOTHING }
|
|
102
|
+
}
|
|
103
|
+
const NOTHING = () => false
|
|
104
|
+
|
|
105
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308])
|
|
106
|
+
/** Largest piece of a media file one request through the gate asks for: the player asks for the next one itself. */
|
|
107
|
+
export const MEDIA_CHUNK = 4 * 1024 * 1024
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The request to the gate's origins with the gate's headers, so that no redirect carries them anywhere else: the
|
|
111
|
+
* fallback where the headers cannot be added per redirect hop (gate-cdp.mjs, Chromium only). Playwright applies headers given to route.continue() to every redirect that follows (and does not route the
|
|
112
|
+
* redirected requests), so the request is made here instead, one hop at a time: a redirect that leaves the gate's
|
|
113
|
+
* origins goes back to the browser, which follows it without the headers. A subresource's redirect within the gate
|
|
114
|
+
* is followed here with the headers; a navigation's goes back to the browser too, so the address bar stays right
|
|
115
|
+
* (the next page then gets in with the gate's cookie, as Cloudflare Access sets one and Vercel does with
|
|
116
|
+
* x-vercel-set-bypass-cookie, or with the Basic challenge). `blocked(method, url)` is the readonly policy for the
|
|
117
|
+
* hops followed here. Media is asked for in pieces of at most MEDIA_CHUNK bytes, so a long recording never sits in
|
|
118
|
+
* memory whole. Headers the page set itself win, so an application's own Authorization header is left alone.
|
|
119
|
+
*/
|
|
120
|
+
export async function continueThroughGate (route, gateHeadersToAdd, toGate, blocked = () => null) {
|
|
121
|
+
const request = route.request()
|
|
122
|
+
const current = request.headers()
|
|
123
|
+
const extra = Object.keys(gateHeadersToAdd).filter((n) => !(n.toLowerCase() in current))
|
|
124
|
+
if (!extra.length) return route.continue()
|
|
125
|
+
// an event stream never ends, so it cannot be fetched first; it keeps the headers on its (rare) redirects
|
|
126
|
+
if (request.resourceType() === 'eventsource' || /text\/event-stream/i.test(current.accept || '')) {
|
|
127
|
+
return route.continue({ headers: { ...current, ...Object.fromEntries(extra.map((n) => [n, gateHeadersToAdd[n]])) } })
|
|
128
|
+
}
|
|
129
|
+
let headers = { ...current, ...Object.fromEntries(extra.map((n) => [n, gateHeadersToAdd[n]])) }
|
|
130
|
+
if (request.resourceType() === 'media') headers = boundedRange(headers)
|
|
131
|
+
let url = request.url(); let method = request.method(); let postData = request.postDataBuffer() ?? undefined
|
|
132
|
+
try {
|
|
133
|
+
for (let hop = 0; hop < 20; hop++) {
|
|
134
|
+
const response = await route.fetch({ url, method, headers, postData, maxRedirects: 0, timeout: 0 })
|
|
135
|
+
const location = REDIRECTS.has(response.status()) ? response.headers().location : null
|
|
136
|
+
if (!location) return await route.fulfill({ response })
|
|
137
|
+
const next = new URL(location, url).href
|
|
138
|
+
if (request.isNavigationRequest() || !toGate(next) || new URL(next).protocol !== new URL(request.url()).protocol) {
|
|
139
|
+
return await route.fulfill({ response, headers: { ...response.headers(), location: next } })
|
|
140
|
+
}
|
|
141
|
+
if (response.status() === 303 || (method === 'POST' && response.status() !== 307 && response.status() !== 308)) {
|
|
142
|
+
method = 'GET'; postData = undefined
|
|
143
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([n]) => !/^content-(type|length|encoding|language|location)$/i.test(n)))
|
|
144
|
+
}
|
|
145
|
+
url = next
|
|
146
|
+
if (blocked(method, url)) return await route.abort('blockedbyclient')
|
|
147
|
+
}
|
|
148
|
+
return await route.abort('failed') // a redirect loop, as the browser would end it
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (/closed|disposed/i.test(String(error?.message))) return
|
|
151
|
+
return route.abort('failed').catch(() => {})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A media Range header with an open end (`bytes=N-`, what players send) limited to MEDIA_CHUNK bytes. */
|
|
156
|
+
export function boundedRange (headers) {
|
|
157
|
+
const name = Object.keys(headers).find((n) => n.toLowerCase() === 'range')
|
|
158
|
+
const m = name && String(headers[name]).match(/^bytes=(\d+)-$/)
|
|
159
|
+
if (!m) return headers
|
|
160
|
+
const start = Number(m[1])
|
|
161
|
+
return { ...headers, [name]: `bytes=${start}-${start + MEDIA_CHUNK - 1}` }
|
|
162
|
+
}
|
|
163
|
+
|
|
81
164
|
/**
|
|
82
165
|
* Enforces the environment on a browser context: the readonly policy for every request and the
|
|
83
|
-
* gate headers for the gate's origins only (third parties never see them
|
|
84
|
-
*
|
|
166
|
+
* gate headers for the gate's origins only (third parties never see them, not even through a redirect).
|
|
167
|
+
* The context is expected to carry contextOptions' httpCredentials, which answer HTTP Basic.
|
|
168
|
+
*
|
|
169
|
+
* On Chromium the headers are added per request and per redirect hop below Playwright's routing (gate-cdp.mjs), so
|
|
170
|
+
* a redirect within the gate keeps them and one that leaves it never gets them. Elsewhere, or when that cannot be
|
|
171
|
+
* set up (or with `perHop: false`), continueThroughGate makes the requests itself.
|
|
85
172
|
*/
|
|
86
|
-
export async function prepareContext (context, env, { log } = {}) {
|
|
87
|
-
const headers =
|
|
88
|
-
const
|
|
89
|
-
|
|
173
|
+
export async function prepareContext (context, env, { log, perHop: allowPerHop = true } = {}) {
|
|
174
|
+
const { headers, toGate } = gateRouting(env)
|
|
175
|
+
const perHop = toGate !== NOTHING && allowPerHop && await gateHeadersPerHop(context, { headers, origins: gateOrigins(env), toGate })
|
|
176
|
+
if (toGate !== NOTHING && !perHop) {
|
|
177
|
+
// a page answered through the gate (continueThroughGate) counts as public to Chrome, which then blocks its
|
|
178
|
+
// requests to local and private addresses (an intranet API, a staging host on a VPN); the gate's own origins
|
|
179
|
+
// may reach them, as the page could when it came straight from the network
|
|
180
|
+
const exact = [...new Set([...gateOrigins(env), ...baseOrigins(env)].filter((o) => !String(o).includes('*') && toGate(o)).map((o) => new URL(o).origin))]
|
|
181
|
+
for (const origin of exact) await context.grantPermissions(['local-network-access'], { origin }).catch(() => {})
|
|
182
|
+
}
|
|
90
183
|
await context.route('**/*', (route) => {
|
|
91
184
|
const request = route.request()
|
|
92
185
|
const reason = policyViolation(env, { method: request.method(), url: request.url() })
|
|
@@ -94,10 +187,12 @@ export async function prepareContext (context, env, { log } = {}) {
|
|
|
94
187
|
log?.({ type: 'blocked', url: request.url(), text: reason }, request)
|
|
95
188
|
return route.abort('blockedbyclient')
|
|
96
189
|
}
|
|
97
|
-
if (toGate(request.url())) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
190
|
+
if (toGate(request.url()) && !perHop) {
|
|
191
|
+
return continueThroughGate(route, headers, toGate, (method, url) => {
|
|
192
|
+
const hop = policyViolation(env, { method, url })
|
|
193
|
+
if (hop) log?.({ type: 'blocked', url, text: hop }, request)
|
|
194
|
+
return hop
|
|
195
|
+
})
|
|
101
196
|
}
|
|
102
197
|
return route.continue()
|
|
103
198
|
})
|
|
@@ -109,12 +204,10 @@ export async function contextOptions (env, { root, config, account } = {}) {
|
|
|
109
204
|
const options = {}
|
|
110
205
|
const certs = clientCertificates(env, root)
|
|
111
206
|
if (certs.length) options.clientCertificates = certs
|
|
112
|
-
// Basic auth
|
|
113
|
-
//
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
options.httpCredentials = { username: secret(gate.basic.user_env, `HTTP Basic user for ${env.name}`), password: secret(gate.basic.password_env, `HTTP Basic password for ${env.name}`), origin: new URL(origins[0]).origin }
|
|
117
|
-
}
|
|
207
|
+
// Basic auth through the browser's own 401 challenge, for the one exact gate origin only (#56, #224): it reaches
|
|
208
|
+
// navigations that arrive through a redirect, and a redirect to another host never carries it
|
|
209
|
+
const credentials = basicCredentials(env)
|
|
210
|
+
if (credentials) options.httpCredentials = credentials
|
|
118
211
|
if (account?.storage_state && await exists(account.storage_state).then(() => true, () => false)) options.storageState = account.storage_state
|
|
119
212
|
return options
|
|
120
213
|
}
|
|
@@ -4,18 +4,21 @@
|
|
|
4
4
|
// Secrets are resolved here from the inherited environment, never passed in argv.
|
|
5
5
|
import { chromium } from 'playwright'
|
|
6
6
|
import { writeJson } from './util.mjs'
|
|
7
|
-
import { prepareContext, clientCertificates } from './access.mjs'
|
|
7
|
+
import { prepareContext, clientCertificates, basicCredentials } from './access.mjs'
|
|
8
8
|
import { launchPersistentWithRetry } from './browser.mjs'
|
|
9
9
|
import { consoleWriter, pageTarget } from './console.mjs'
|
|
10
10
|
|
|
11
11
|
const opts = JSON.parse(process.argv[2])
|
|
12
12
|
const certs = opts.env ? clientCertificates(opts.env, opts.root) : []
|
|
13
|
+
// HTTP Basic of a one-origin gate answers the 401 challenge of that origin only (#224)
|
|
14
|
+
const credentials = opts.env ? basicCredentials(opts.env) : null
|
|
13
15
|
// a time limit and one more try (KALOKO_LAUNCH_TIMEOUT): a start that stalls on a busy machine does not hang here
|
|
14
16
|
const context = await launchPersistentWithRetry(chromium, opts.profile, {
|
|
15
17
|
headless: !opts.headed,
|
|
16
18
|
channel: opts.channel || undefined,
|
|
17
19
|
viewport: { width: 1280, height: 800 },
|
|
18
20
|
...(certs.length ? { clientCertificates: certs } : {}),
|
|
21
|
+
...(credentials ? { httpCredentials: credentials } : {}),
|
|
19
22
|
args: [`--remote-debugging-port=${opts.port}`]
|
|
20
23
|
})
|
|
21
24
|
|
package/src/lib/build.mjs
CHANGED
|
@@ -98,6 +98,8 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
|
|
|
98
98
|
// "personal data hidden" (#64): the canvas says so, so a blank is not taken for a bug
|
|
99
99
|
...(runPii(meta, nodes) ? { pii: runPii(meta, nodes) } : {}),
|
|
100
100
|
...(site ? { site } : {}),
|
|
101
|
+
// packs a design environment turned off (#205; packs.mjs forEnvironment): the app says why they are missing
|
|
102
|
+
...(scenario.packs_off ? { packs_off: scenario.packs_off } : {}),
|
|
101
103
|
coverage: cov,
|
|
102
104
|
history,
|
|
103
105
|
catalog,
|
package/src/lib/changelog.mjs
CHANGED
|
@@ -279,7 +279,8 @@ export async function productPayload (root, product, src) {
|
|
|
279
279
|
* `kaloko products sync`: every product of the config (or the one asked for) on Kaloko with its name, projects,
|
|
280
280
|
* modules, version source, reference environment, default scenario and who should read it (publish:, which the
|
|
281
281
|
* service applies only for someone who manages the product), without recording a release. A product the service
|
|
282
|
-
* refuses (a project it does not know or the person may not map) is reported and the others still go.
|
|
282
|
+
* refuses (a project it does not know or the person may not map) is reported and the others still go. An archived
|
|
283
|
+
* product (kaloko products remove) is not created again: it is reported with the way back (kaloko products restore).
|
|
283
284
|
*/
|
|
284
285
|
export async function syncProducts ({ root, config, client = null, only = null, dryRun = false }) {
|
|
285
286
|
const all = productsOf(config, root)
|
|
@@ -293,7 +294,10 @@ export async function syncProducts ({ root, config, client = null, only = null,
|
|
|
293
294
|
try {
|
|
294
295
|
const saved = await client.request('POST', `/orgs/${config.org}/products`, payload)
|
|
295
296
|
out.push({ ...item, ok: true, created: saved.created ?? null, publish: saved.product?.publish || null, url: saved.page || saved.url || null, changelog: saved.url || null, notes: saved.notes || [] })
|
|
296
|
-
} catch (e) {
|
|
297
|
+
} catch (e) {
|
|
298
|
+
if (e.code === 'product_archived') { out.push({ ...item, ok: null, archived: true, restore: `kaloko products restore ${product.slug}` }); continue }
|
|
299
|
+
out.push({ ...item, ok: false, error: String(e.message || e).split('\n')[0] })
|
|
300
|
+
}
|
|
297
301
|
}
|
|
298
302
|
return { dry_run: dryRun, from_config: !!Object.keys(config.products || {}).length, products: out }
|
|
299
303
|
}
|
|
@@ -303,15 +307,18 @@ export function formatSync (r) {
|
|
|
303
307
|
const lines = []
|
|
304
308
|
const n = r.products.length
|
|
305
309
|
lines.push(r.dry_run ? `${n} product(s) would go to Kaloko; dry run, nothing sent.` : `${r.products.filter((p) => p.ok).length} of ${n} product(s) on Kaloko; no release recorded.`)
|
|
310
|
+
const archived = r.products.filter((p) => p.archived)
|
|
306
311
|
if (!r.from_config) lines.push(' No products: in kaloko.config.yml, so the project is the product (its own changelog and docs).')
|
|
307
312
|
for (const p of r.products) {
|
|
308
313
|
const what = [`${p.projects.length ? `projects ${p.projects.join(', ')}` : 'the whole organization'}`, p.modules.length ? `modules ${p.modules.join(', ')}` : null, `version from ${p.version_source.source}${p.version ? ` (${p.version})` : ''}`].filter(Boolean).join(' · ')
|
|
309
314
|
const name = typeof p.name === 'object' && p.name ? Object.values(p.name)[0] : p.name
|
|
315
|
+
if (p.archived) { lines.push(` − ${p.slug}${name && name !== p.slug ? ` (${name})` : ''}: archived on Kaloko, left as it is`); continue }
|
|
310
316
|
lines.push(` ${p.ok === false ? '✗' : r.dry_run ? '·' : p.created ? '+' : '✓'} ${p.slug}${name && name !== p.slug ? ` (${name})` : ''}: ${what}`)
|
|
311
317
|
if (p.url) lines.push(` ${p.url}`)
|
|
312
318
|
if (p.error) lines.push(` ! ${p.error}`)
|
|
313
319
|
for (const x of p.notes || []) lines.push(` ! ${x}`)
|
|
314
320
|
}
|
|
321
|
+
if (archived.length) lines.push(`Archived products stay archived. To bring one back with its releases and docs: ${archived.map((p) => p.restore).join('; ')}. Or delete it from products: in kaloko.config.yml.`)
|
|
315
322
|
return lines.join('\n')
|
|
316
323
|
}
|
|
317
324
|
|
|
@@ -321,9 +328,43 @@ export function formatSync (r) {
|
|
|
321
328
|
*/
|
|
322
329
|
export async function listProducts ({ root, config, client }) {
|
|
323
330
|
const service = await client.request('GET', `/orgs/${config.org}/products`)
|
|
331
|
+
// archived products the person reads (kaloko products remove); an older service answers the plain list: none
|
|
332
|
+
const archived = (await client.request('GET', `/orgs/${config.org}/products?archived=1`).catch(() => [])).filter((p) => p?.archived_at)
|
|
324
333
|
const local = Object.keys(config.products || {}).length ? productsOf(config, root) : []
|
|
325
|
-
const known = new Set(service.map((p) => p.slug))
|
|
326
|
-
return { products: service, missing: local.filter((p) => !known.has(p.slug)).map((p) => p.slug) }
|
|
334
|
+
const known = new Set([...service, ...archived].map((p) => p.slug))
|
|
335
|
+
return { products: service, archived, missing: local.filter((p) => !known.has(p.slug)).map((p) => p.slug) }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const productArg = (slug) => {
|
|
339
|
+
const s = String(slug || '').trim().toLowerCase()
|
|
340
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(s)) throw new UserError('Name the product: kaloko products remove <product> (its slug, as kaloko products list shows it).')
|
|
341
|
+
return s
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* `kaloko products remove <product>`: archives it on Kaloko (#211). It leaves the lists, its changelog and docs pages
|
|
346
|
+
* answer 404, kaloko products sync does not create it again; its releases and docs stay for kaloko products restore.
|
|
347
|
+
*/
|
|
348
|
+
export async function removeProduct ({ config, client, slug }) {
|
|
349
|
+
const s = productArg(slug)
|
|
350
|
+
const r = await client.request('DELETE', `/orgs/${config.org}/products/${encodeURIComponent(s)}`)
|
|
351
|
+
return { slug: s, archived: true, releases: r.releases ?? null, restore: `kaloko products restore ${s}` }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** `kaloko products restore <product>`: an archived product back as it was, with its releases and docs. */
|
|
355
|
+
export async function restoreProduct ({ config, client, slug }) {
|
|
356
|
+
const s = productArg(slug)
|
|
357
|
+
const r = await client.request('POST', `/orgs/${config.org}/products/${encodeURIComponent(s)}/restore`, {})
|
|
358
|
+
return { slug: s, restored: !!r.restored, url: r.url || null }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function formatRemove (r) {
|
|
362
|
+
const kept = r.releases ? ` Its ${r.releases} release(s) and its docs are kept.` : ' Its releases and docs are kept.'
|
|
363
|
+
return `${r.slug} is archived: it is gone from Products, and its changelog and docs pages answer 404.${kept}\n Undo: ${r.restore}\n kaloko products sync will not create it again; delete it from products: in kaloko.config.yml too.`
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function formatRestore (r) {
|
|
367
|
+
return r.restored ? `${r.slug} is back, with its releases and docs.${r.url ? `\n ${r.url}` : ''}` : `${r.slug} was not archived; nothing changed.${r.url ? `\n ${r.url}` : ''}`
|
|
327
368
|
}
|
|
328
369
|
|
|
329
370
|
/** The list for people. */
|
|
@@ -339,6 +380,7 @@ export function formatList (r) {
|
|
|
339
380
|
if (p.projects?.length) lines.push(` projects: ${p.projects.join(', ')}`)
|
|
340
381
|
if (p.url) lines.push(` ${p.url}`)
|
|
341
382
|
}
|
|
383
|
+
if (r.archived?.length) lines.push(`Archived: ${r.archived.map((p) => p.slug).join(', ')}. kaloko products restore <product> brings one back.`)
|
|
342
384
|
if (r.missing.length) lines.push(`In kaloko.config.yml but not on Kaloko yet: ${r.missing.join(', ')}. Run kaloko products sync.`)
|
|
343
385
|
return lines.join('\n')
|
|
344
386
|
}
|
package/src/lib/cli-args.mjs
CHANGED
|
@@ -48,7 +48,7 @@ export const HIDDEN = {
|
|
|
48
48
|
* Commands whose unknown options stop the command instead of a warning: they delete, publish or change access. A
|
|
49
49
|
* "command sub" entry makes only that sub-command strict (kaloko projects lists, kaloko projects create writes).
|
|
50
50
|
*/
|
|
51
|
-
export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment', 'projects create', 'projects rename', 'projects sync'])
|
|
51
|
+
export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment', 'projects create', 'projects rename', 'projects sync', 'scenario move'])
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Options a command takes more than once (kaloko start --ref A --ref B): their value is the list of every value given.
|
package/src/lib/design-cli.mjs
CHANGED
|
@@ -181,7 +181,7 @@ export function syncScope (scope, locales) {
|
|
|
181
181
|
/** Scenario snapshot the service keeps with the draft (so it can compose versions without the CLI). */
|
|
182
182
|
export function snapshotOf (scenario, run) {
|
|
183
183
|
const pos = Object.fromEntries(layout(scenario).map((p) => [p.id, { col: p.col, lane: p.lane }]))
|
|
184
|
-
return { title: scenario.title, kind: scenario.kind || 'task', source: scenario.source || null, file: run.meta.scenario_file, project: run.meta.project || null, nodes: scenario.nodes, edges: scenario.edges, lanes: scenario.lanes || [], sessions: scenario.sessions || null, layout: pos }
|
|
184
|
+
return { title: scenario.title, kind: scenario.kind || 'task', source: scenario.source || null, file: run.meta.scenario_file, project: run.meta.project || null, nodes: scenario.nodes, edges: scenario.edges, lanes: scenario.lanes || [], sessions: scenario.sessions || null, layout: pos, ...(scenario.packs_off ? { packs_off: scenario.packs_off } : {}) }
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
export async function uploadBlobs ({ client, org, files, say }) {
|