create-ailk 0.28.0 → 0.29.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 +124 -1
- package/dist/cli.js +92 -2
- package/dist/lib/chrome-readers.d.ts +21 -5
- package/dist/lib/chrome-readers.js +128 -20
- package/dist/lib/declared-env.d.ts +6 -0
- package/dist/lib/declared-env.js +2 -0
- package/dist/lib/import-graph.d.ts +16 -0
- package/dist/lib/import-graph.js +53 -0
- package/dist/lib/import-mdx.d.ts +59 -0
- package/dist/lib/import-mdx.js +452 -0
- package/dist/migrate-content.d.ts +45 -0
- package/dist/migrate-content.js +215 -0
- package/dist/seed-workspace.d.ts +71 -0
- package/dist/seed-workspace.js +242 -0
- package/dist/template-record.d.ts +13 -4
- package/dist/template-record.js +8 -4
- package/dist/update.d.ts +32 -2
- package/dist/update.js +357 -44
- package/package.json +3 -3
- package/templates/apps/api/CLAUDE.md +1 -1
- package/templates/apps/mcp/CLAUDE.md +16 -12
- package/templates/apps/web/CLAUDE.md +11 -1
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +4 -0
- package/templates/apps/web/app/[locale]/alternatives/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/careers/page.tsx +23 -15
- package/templates/apps/web/app/[locale]/clients/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/faq/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/glossary/page.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +6 -0
- package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +1 -0
- package/templates/apps/web/lib/hub-children.tsx +38 -9
- package/templates/apps/web/messages/en.json +22 -2
- package/templates/tools/declared-env.ts +7 -0
- package/versions.json +48 -48
package/README.md
CHANGED
|
@@ -552,6 +552,128 @@ deliverables, pricing, registers, listings — so a flow that needs one is
|
|
|
552
552
|
authored in the hub's Admin; once it carries one, the hub refuses a seed of
|
|
553
553
|
that flow (`409 slug_owned`) rather than delete the block.
|
|
554
554
|
|
|
555
|
+
### Seeding the workspace
|
|
556
|
+
|
|
557
|
+
On a site with an Admin (the `workspace` or `multi-tenant` module), the
|
|
558
|
+
starter's pages are also rows in the site's workspace, so the Admin's Docs
|
|
559
|
+
lists Home, About, Contact and every other roster page on first open.
|
|
560
|
+
`seed-workspace` writes them there: each `content/en/<Type>/<slug>.mdx` page
|
|
561
|
+
becomes one draft `text/page` Artifact.
|
|
562
|
+
|
|
563
|
+
```bash
|
|
564
|
+
AILK_AGENT_KEY=<agent key> create-ailk seed-workspace ./my-site --api-url <api origin> --workspace <wksp_id>
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
- The API origin is `--api-url`, else `AILK_API_URL`; it must be `https:` (or
|
|
568
|
+
`http:` on a loopback host). The workspace id is `--workspace`, else
|
|
569
|
+
`AILK_WORKSPACE_ID`. The first line printed names the workspace, the origin
|
|
570
|
+
and the origin's source, before any request.
|
|
571
|
+
- The agent key is read from `AILK_AGENT_KEY` only — never a flag or a file —
|
|
572
|
+
and never printed. An owner issues it in the Admin with the `content-edit`
|
|
573
|
+
grant.
|
|
574
|
+
- Each page prints `created`, `updated`, `unchanged`, `skipped` or `failed`,
|
|
575
|
+
then a count line. A re-run creates nothing and reports every page
|
|
576
|
+
`unchanged`. An agent writes ten times a minute, so a first seed of about
|
|
577
|
+
thirty pages waits out the rate limit a few times.
|
|
578
|
+
- The site's dependencies must be installed (`pnpm install`): the pages are
|
|
579
|
+
read through the site's own `@working-theory/content-adapters`.
|
|
580
|
+
- The API must run with `content-write` and a tenancy module (`workspace` or
|
|
581
|
+
`multi-tenant`) in `AILK_ENABLED_MODULES`. Without them the content routes
|
|
582
|
+
are not served and every page fails `404`.
|
|
583
|
+
- Exit 0 when every page was seeded, 1 when any failed, 2 on a refusal (no
|
|
584
|
+
`.ailk-site-manifest.json`, a spoke, a missing key, workspace id or origin,
|
|
585
|
+
the adapter not installed), which sends nothing.
|
|
586
|
+
|
|
587
|
+
`pnpm create ailk` runs the same seed as its last step, after `--install`,
|
|
588
|
+
when the site has an Admin and `AILK_API_URL`, `AILK_WORKSPACE_ID` and
|
|
589
|
+
`AILK_AGENT_KEY` are all set. When the site has an Admin but one is missing,
|
|
590
|
+
or `--install` was not passed, it prints that seeding was skipped and the
|
|
591
|
+
command above. A site with no Admin, or a spoke, prints nothing about it. A
|
|
592
|
+
seed that fails or is refused at scaffold time prints why and never fails the
|
|
593
|
+
scaffold; `seed-workspace` is where a seed's result is an exit code.
|
|
594
|
+
|
|
595
|
+
On a site carrying `signal-collectors`, the two `ThankYouPage` seeds
|
|
596
|
+
`contact` and `newsletter` share an address (locale and slug) with the
|
|
597
|
+
`ContactPage` and `NewsletterSignup` pages, so those two are reported
|
|
598
|
+
`failed` and the seed exits 1 until one side's slug changes.
|
|
599
|
+
|
|
600
|
+
To check a seed:
|
|
601
|
+
|
|
602
|
+
```bash
|
|
603
|
+
cd my-site
|
|
604
|
+
# 1. Re-run the seed: nothing may be created, updated or fail.
|
|
605
|
+
AILK_AGENT_KEY=<agent key> create-ailk seed-workspace . --api-url <api origin> --workspace <wksp_id>
|
|
606
|
+
# expect the count line: created 0 · updated 0 · unchanged <N> · skipped <S> · failed 0
|
|
607
|
+
# 2. Read the workspace's pages back: at least <N> rows.
|
|
608
|
+
curl -sS -H "authorization: Bearer $AILK_AGENT_KEY" "<api origin>/v1/workspaces/<wksp_id>/content?locale=en" | jq '.data | length'
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
### Migrating an existing site's content
|
|
612
|
+
|
|
613
|
+
`migrate-content` imports the pages of a site that was not seeded at
|
|
614
|
+
scaffold time — a site with no `.ailk-site-manifest.json`, or a spoke — into
|
|
615
|
+
a workspace: each `content/en/<Type>/<slug>.mdx` page, and each legacy
|
|
616
|
+
`content/<Type>/<slug>.mdx` page, becomes one draft `text/page` Artifact.
|
|
617
|
+
|
|
618
|
+
```bash
|
|
619
|
+
AILK_AGENT_KEY=<agent key> create-ailk migrate-content ./my-site --api-url <api origin> --workspace <wksp_id>
|
|
620
|
+
AILK_AGENT_KEY=<agent key> create-ailk migrate-content ./my-site --apply --api-url <api origin> --workspace <wksp_id>
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
- It is a dry run by default and writes nothing: it reads the workspace,
|
|
624
|
+
prints what `--apply` would do to each page — `create`, `update`,
|
|
625
|
+
`unchanged`, `skipped` or `failed` — then a count line. A dry run never sends
|
|
626
|
+
a write: its transport answers a create or an update itself.
|
|
627
|
+
- `--apply` writes. Each page prints `created`, `updated`, `unchanged`,
|
|
628
|
+
`skipped` or `failed`, then a count line; a re-run creates nothing and
|
|
629
|
+
reports every page `unchanged`.
|
|
630
|
+
- The API origin is `--api-url`, else `AILK_API_URL`; it must be `https:` (or
|
|
631
|
+
`http:` on a loopback host). The workspace id is `--workspace`, else
|
|
632
|
+
`AILK_WORKSPACE_ID`. The agent key is read from `AILK_AGENT_KEY` only —
|
|
633
|
+
never a flag or a file — and never printed; it needs the `content-edit`
|
|
634
|
+
grant. The first line printed names the workspace, the origin and the
|
|
635
|
+
origin's source, before any request.
|
|
636
|
+
- On a spoke, the workspace is its hub's: pass the hub's API origin and the
|
|
637
|
+
spoke's workspace on the hub.
|
|
638
|
+
- Pages land as drafts and nothing is published, so the live site keeps
|
|
639
|
+
serving its MDX files and does not change.
|
|
640
|
+
- The plan cannot see a write the API would refuse at `--apply` (a conflict
|
|
641
|
+
with an edit made in between, or a server error): such a page is planned
|
|
642
|
+
`create` or `update`. A page whose content fails its page type is planned
|
|
643
|
+
and then saved with the errors the editor shows, not `failed`.
|
|
644
|
+
- The site's dependencies must be installed (`pnpm install`): the pages are
|
|
645
|
+
read through the site's own `@working-theory/content-adapters`.
|
|
646
|
+
- Exit 0 when no page failed, 1 when any did (in a dry run, when any would), 2
|
|
647
|
+
on a refusal (a missing key, workspace id or origin, no `content/`, the
|
|
648
|
+
adapter not installed), which sends nothing.
|
|
649
|
+
|
|
650
|
+
To migrate a live site and prove a visitor sees no change (samhenry.org's
|
|
651
|
+
sequence; the placeholders are yours to fill):
|
|
652
|
+
|
|
653
|
+
```bash
|
|
654
|
+
cd <the samhenry repo> # dependencies installed (pnpm install)
|
|
655
|
+
export AILK_AGENT_KEY=<agent key> # content-edit, issued in the hub's Admin for the Sam Henry workspace
|
|
656
|
+
API=<hub api origin>; WS=<wksp_id>
|
|
657
|
+
# 0. Snapshot the live site before anything is written.
|
|
658
|
+
curl -sS https://samhenry.org/sitemap.xml | grep -o '<loc>[^<]*</loc>' | sed -e 's/<loc>//' -e 's/<\/loc>//' | sort -u > urls.txt
|
|
659
|
+
mkdir -p before after
|
|
660
|
+
n=0; while read -r u; do n=$((n+1)); curl -sS -o "before/$n.html" -w '%{http_code} %{url_effective}\n' "$u"; done < urls.txt > before/status.txt
|
|
661
|
+
# 1. Dry run: the plan; nothing is written. Expect failed 0 and exit 0.
|
|
662
|
+
npx create-ailk@<version> migrate-content . --api-url "$API" --workspace "$WS"
|
|
663
|
+
# 2. Apply. Expect: created <N> · updated 0 · unchanged 0 · skipped <S> · failed 0
|
|
664
|
+
npx create-ailk@<version> migrate-content . --apply --api-url "$API" --workspace "$WS"
|
|
665
|
+
# 3. Re-run: nothing changes. Expect: created 0 · updated 0 · unchanged <N> · skipped <S> · failed 0
|
|
666
|
+
npx create-ailk@<version> migrate-content . --apply --api-url "$API" --workspace "$WS"
|
|
667
|
+
# 4. Docs lists them: at least <N> rows (exactly <N> on a workspace that had no pages).
|
|
668
|
+
printf 'authorization: Bearer %s\n' "$AILK_AGENT_KEY" | curl -sS -H @- "$API/v1/workspaces/$WS/content?locale=en" | jq '.data | length'
|
|
669
|
+
# 5. The live diff: re-fetch the same URLs and compare. Expect no output from diff.
|
|
670
|
+
n=0; while read -r u; do n=$((n+1)); curl -sS -o "after/$n.html" -w '%{http_code} %{url_effective}\n' "$u"; done < urls.txt > after/status.txt
|
|
671
|
+
diff -r before after && echo "live site unchanged"
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
A non-empty diff is a finding: stop and report it before migrating another
|
|
675
|
+
site.
|
|
676
|
+
|
|
555
677
|
### A spoke's declared environment
|
|
556
678
|
|
|
557
679
|
A spoke scaffold's `pnpm preflight` and CI run `pnpm env:declared`: every
|
|
@@ -638,7 +760,8 @@ The network invariant (#6759 D6), in full:
|
|
|
638
760
|
3. Substitutes `workspace:*` references in every `package.json` with the matching `@working-theory/*` semver from npm — versions are pinned at npm-publish time.
|
|
639
761
|
4. Substitutes `{{PROJECT_NAME}}`, `{{COMPANY_NAME}}`, `{{COMPANY_DOMAIN}}` markers in `templates/README.md` from the target directory name (and CLI flags / interactive prompts in future versions).
|
|
640
762
|
5. Derives and writes `architecture.yaml` (the site's code-boundary config) from the selected module set — see below.
|
|
641
|
-
6.
|
|
763
|
+
6. On a site with an Admin, seeds the starter's pages into its workspace when `--install` ran and `AILK_API_URL`, `AILK_WORKSPACE_ID` and `AILK_AGENT_KEY` are set, else prints why not and the `seed-workspace` command — see [Seeding the workspace](#seeding-the-workspace).
|
|
764
|
+
7. Prints next steps: `cd <dir> && pnpm install && pnpm dev`.
|
|
642
765
|
|
|
643
766
|
## `architecture.yaml` is derived from the module set
|
|
644
767
|
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
* on a `seed-flows` subcommand: send a spoke's declared flows/*.json to
|
|
15
15
|
* its hub with the key from AILK_SPOKE_KEY, print one line per flow,
|
|
16
16
|
* exit 1 if any failed (2 on a refusal it names, nothing sent) (#7876)
|
|
17
|
+
* on a `seed-workspace` subcommand: import a starter's content/ pages
|
|
18
|
+
* into its workspace with the agent key from AILK_AGENT_KEY, print one
|
|
19
|
+
* line per page and the count line, exit 1 if any failed (2 on a
|
|
20
|
+
* refusal it names, nothing sent) (#10204)
|
|
21
|
+
* on a `migrate-content` subcommand: plan an existing site's content/
|
|
22
|
+
* pages against its workspace — or, with --apply, write them — with the
|
|
23
|
+
* agent key from AILK_AGENT_KEY, print one line per page and the count
|
|
24
|
+
* line, exit 1 if any failed (2 on a refusal it names, nothing sent)
|
|
25
|
+
* (#10207)
|
|
17
26
|
* on a `sync-routes` subcommand: parse its own argv, run syncRoutesSync(),
|
|
18
27
|
* print the report, exit (1 iff routes are missing in check mode)
|
|
19
28
|
* on a `license set` subcommand: write the key into the scaffold, push it
|
|
@@ -31,7 +40,11 @@
|
|
|
31
40
|
* template's own bundle (#5481), then validates + copies the local
|
|
32
41
|
* template, then merges each fetched bundle in, the site template last
|
|
33
42
|
* 5. when --install: spawn `pnpm install` (cwd = target, hard-coded argv)
|
|
34
|
-
* 6.
|
|
43
|
+
* 6. on a hub with an Admin: seed the starter's pages into the workspace
|
|
44
|
+
* when --install ran and AILK_API_URL, AILK_WORKSPACE_ID and
|
|
45
|
+
* AILK_AGENT_KEY are set, else print why not and the command that seeds
|
|
46
|
+
* it later; never fails the scaffold (#10204)
|
|
47
|
+
* 7. print next-steps message
|
|
35
48
|
*
|
|
36
49
|
* Any thrown error (parse failure, validation failure, copy failure,
|
|
37
50
|
* `LicenseRequiredError`) is caught at the top level: stderr
|
|
@@ -55,14 +68,17 @@ import { ADOPT_HELP, AdoptRefusedError, adoptSite, formatAdoptReport, parseAdopt
|
|
|
55
68
|
import { APPLY_DESIGN_HELP, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, } from "./apply-design.js";
|
|
56
69
|
import { CLAUDE_MD_CHECK_PATH } from "./claude-md.js";
|
|
57
70
|
import { buildScaffoldMarkers } from "./copy.js";
|
|
71
|
+
import { ImportMdxRefusedError } from "./lib/import-mdx.js";
|
|
58
72
|
import { assertKeyedOrFreeReleased, assertReleased } from "./lib/module-license-gate.js";
|
|
59
73
|
import { withNpmTokenUserConfig } from "./lib/npm-auth.js";
|
|
60
74
|
import { FREE_PREREQUISITES_NOTE, LICENSED_PREREQUISITES_NOTE, isLicensedInstall, proCallout, } from "./lib/pro-callout.js";
|
|
61
75
|
import { LICENSE_SET_HELP, runLicenseSet } from "./license-set.js";
|
|
62
|
-
import {
|
|
76
|
+
import { MIGRATE_CONTENT_HELP, MigrateContentRefusedError, migrateContent, parseMigrateContentArgs, } from "./migrate-content.js";
|
|
77
|
+
import { PLUGIN_INSTALL_LINES, ParseError, helpText, parseCliArgs, parseModules, resolveCliShape, toScaffoldOptions, } from "./parse-args.js";
|
|
63
78
|
import { addModule, removeModule, formatSurfaceDrift, scaffold, syncArchitectureSync, syncCatalogSync, syncClaudeMdSync, syncRoutesSync, } from "./programmatic.js";
|
|
64
79
|
import { REMOVE_MODULE_HELP, RemoveModuleRefusedError, formatRemoveModuleReport, parseRemoveModuleArgs, } from "./remove-module.js";
|
|
65
80
|
import { SEED_FLOWS_HELP, SeedFlowsRefusedError, parseSeedFlowsArgs, seedFlows, } from "./seed-flows.js";
|
|
81
|
+
import { SEED_WORKSPACE_HELP, SeedWorkspaceRefusedError, parseSeedWorkspaceArgs, runScaffoldSeed, seedWorkspace, } from "./seed-workspace.js";
|
|
66
82
|
import { CORE_SURFACE_PATHS, SURFACE_ALIASES } from "./surfaces.js";
|
|
67
83
|
import { SYNC_ROUTES_HELP, formatSyncRoutesReport, parseSyncRoutesArgs, } from "./sync-routes.js";
|
|
68
84
|
import { UPDATE_HELP, UpdateRefusedError, formatUpdateReport, parseUpdateArgs, updateSite, } from "./update.js";
|
|
@@ -271,6 +287,67 @@ async function main() {
|
|
|
271
287
|
process.exit(err.exitCode);
|
|
272
288
|
}
|
|
273
289
|
}
|
|
290
|
+
// `seed-workspace [dir]` (#10204): import a starter's content/ pages into
|
|
291
|
+
// its workspace with the agent key from AILK_AGENT_KEY. Exit 1 when any
|
|
292
|
+
// page failed; a refusal it names (a bad argv, no roster of record, a
|
|
293
|
+
// spoke, a missing credential, no installed adapter) exits 2 before
|
|
294
|
+
// anything is sent.
|
|
295
|
+
if (argv[0] === "seed-workspace") {
|
|
296
|
+
try {
|
|
297
|
+
const args = parseSeedWorkspaceArgs(argv.slice(1));
|
|
298
|
+
if (args.help) {
|
|
299
|
+
process.stdout.write(SEED_WORKSPACE_HELP);
|
|
300
|
+
process.exit(0);
|
|
301
|
+
}
|
|
302
|
+
const { exitCode } = await seedWorkspace({
|
|
303
|
+
targetDir: args.target ?? process.cwd(),
|
|
304
|
+
apiUrl: args.apiUrl,
|
|
305
|
+
workspaceId: args.workspaceId,
|
|
306
|
+
onLine: (line) => process.stdout.write(`${line}\n`),
|
|
307
|
+
});
|
|
308
|
+
process.exit(exitCode);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
if (!(err instanceof ParseError) &&
|
|
312
|
+
!(err instanceof SeedWorkspaceRefusedError) &&
|
|
313
|
+
!(err instanceof ImportMdxRefusedError)) {
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
process.stderr.write(`[create-ailk] ${err.message}\n`);
|
|
317
|
+
process.exit(2);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// `migrate-content [dir]` (#10207): import an existing site's content/
|
|
321
|
+
// pages into its workspace — a dry run that writes nothing unless --apply.
|
|
322
|
+
// Takes a site with no manifest and a spoke. Exit 1 when any page failed; a
|
|
323
|
+
// refusal it names (a bad argv, a missing credential, no installed adapter)
|
|
324
|
+
// exits 2 before anything is sent.
|
|
325
|
+
if (argv[0] === "migrate-content") {
|
|
326
|
+
try {
|
|
327
|
+
const args = parseMigrateContentArgs(argv.slice(1));
|
|
328
|
+
if (args.help) {
|
|
329
|
+
process.stdout.write(MIGRATE_CONTENT_HELP);
|
|
330
|
+
process.exit(0);
|
|
331
|
+
}
|
|
332
|
+
const { exitCode } = await migrateContent({
|
|
333
|
+
targetDir: args.target ?? process.cwd(),
|
|
334
|
+
apply: args.apply,
|
|
335
|
+
apiUrl: args.apiUrl,
|
|
336
|
+
workspaceId: args.workspaceId,
|
|
337
|
+
onLine: (line) => process.stdout.write(`${line}\n`),
|
|
338
|
+
});
|
|
339
|
+
process.exit(exitCode);
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
if (!(err instanceof ParseError) &&
|
|
343
|
+
!(err instanceof MigrateContentRefusedError) &&
|
|
344
|
+
!(err instanceof ImportMdxRefusedError)) {
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
process.stderr.write(`[create-ailk] ${err.message}\n`);
|
|
348
|
+
process.exit(2);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
274
351
|
// Subcommand dispatch, ahead of the flag parser: `sync-routes` has its own
|
|
275
352
|
// argv shape (#4986). Check mode exits 1 iff a template route is missing;
|
|
276
353
|
// locally modified routes are reported but do not fail; --apply exits 0.
|
|
@@ -393,6 +470,8 @@ async function main() {
|
|
|
393
470
|
"\n" +
|
|
394
471
|
SEED_FLOWS_HELP +
|
|
395
472
|
"\n" +
|
|
473
|
+
SEED_WORKSPACE_HELP +
|
|
474
|
+
"\n" +
|
|
396
475
|
APPLY_DESIGN_HELP +
|
|
397
476
|
"\n" +
|
|
398
477
|
UPDATE_HELP +
|
|
@@ -486,6 +565,17 @@ async function main() {
|
|
|
486
565
|
throw new Error(`pnpm install failed in ${targetAbs} (exit ${result.status}). Run it manually after fixing the issue.`);
|
|
487
566
|
}
|
|
488
567
|
}
|
|
568
|
+
// #10204 — on a hub with an Admin, seed the starter's pages into the
|
|
569
|
+
// workspace, or say why not and how to later. A refusal is printed, never
|
|
570
|
+
// thrown: the site was written and is valid either way (D6).
|
|
571
|
+
await runScaffoldSeed({
|
|
572
|
+
targetDir: targetAbs,
|
|
573
|
+
topology: opts.topology,
|
|
574
|
+
modules: effectiveModules,
|
|
575
|
+
installed: opts.install === true,
|
|
576
|
+
env: process.env,
|
|
577
|
+
onLine: (line) => process.stdout.write(`${line}\n`),
|
|
578
|
+
});
|
|
489
579
|
// Next-steps message. A licensed install (#6480) is not told it needs no
|
|
490
580
|
// licence key, and is not pitched the Pro tier it already owns. Reads the
|
|
491
581
|
// EFFECTIVE module set (#6759 D6) — result.modules already carries any
|
|
@@ -5,17 +5,33 @@ export interface UnreadChromeKnob {
|
|
|
5
5
|
/** Overlay-relative path of the reader file, with `/` separators. */
|
|
6
6
|
file: string;
|
|
7
7
|
}
|
|
8
|
+
/** One overlay file that reads a knob. */
|
|
9
|
+
export interface ChromeReader {
|
|
10
|
+
/** Overlay-relative path, with `/` separators. */
|
|
11
|
+
file: string;
|
|
12
|
+
/** Present when `file` reads the knob only through its imports: the files it reaches that read it directly. */
|
|
13
|
+
via?: string[];
|
|
14
|
+
}
|
|
8
15
|
/** The pattern that means "this text reads `knob`". */
|
|
9
16
|
export declare function chromeReadPattern(knob: string): RegExp;
|
|
10
17
|
/**
|
|
11
|
-
* knob → the overlay files that read it, sorted
|
|
12
|
-
* fails closed (throws, naming the directory)
|
|
13
|
-
* so a broken overlay can never read as
|
|
18
|
+
* knob → the overlay files that read it, sorted by file; one that reads it
|
|
19
|
+
* only through relative imports carries `via`. Scans `<templatesDir>/apps/web`; fails closed (throws, naming the directory)
|
|
20
|
+
* when it is absent or unreadable, so a broken overlay can never read as
|
|
21
|
+
* "nothing reads any knob".
|
|
14
22
|
*/
|
|
15
|
-
export declare function chromeKnobReaders(templatesDir: string): Map<string,
|
|
23
|
+
export declare function chromeKnobReaders(templatesDir: string): Map<string, ChromeReader[]>;
|
|
16
24
|
/**
|
|
17
25
|
* The `(knob, file)` pairs where the resolved chrome sets a non-default `knob`,
|
|
18
26
|
* the shipped overlay's `file` reads it, the site has its own `file`, and the
|
|
19
27
|
* site's `file` does not read it. A reader file the site lacks is skipped.
|
|
28
|
+
*
|
|
29
|
+
* A pair the overlay credits only through an import is also satisfied when
|
|
30
|
+
* some site file outside its `via` reads the knob directly: a site from before
|
|
31
|
+
* the read moved into the helper renders it in its own place (#10156). So the
|
|
32
|
+
* import credit adds a pair only for a knob the site reads nowhere but its
|
|
33
|
+
* helpers. It is per file, not per export: a site whose home page drops the
|
|
34
|
+
* helper import is named for every knob that helper reads — a refusal the
|
|
35
|
+
* operator passes with `--allow-unread-chrome`, never a silent miss.
|
|
20
36
|
*/
|
|
21
|
-
export declare function unreadChromeKnobs(resolved: ResolvedSiteChrome, readers: ReadonlyMap<string, readonly
|
|
37
|
+
export declare function unreadChromeKnobs(resolved: ResolvedSiteChrome, readers: ReadonlyMap<string, readonly ChromeReader[]>, siteDir: string): UnreadChromeKnob[];
|
|
@@ -11,12 +11,16 @@
|
|
|
11
11
|
* each knob — nothing is hard-coded, so a new reader file is picked up.
|
|
12
12
|
* - `unreadChromeKnobs` compares the site's own copy of each reader file.
|
|
13
13
|
*
|
|
14
|
-
* "
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* A file "reads `knob` directly" when its text matches
|
|
15
|
+
* `(?:siteChrome|chrome)\.<knob>\b`, and "reads" it when it or a file it
|
|
16
|
+
* imports by a relative path, at any depth, reads it directly — so the layout
|
|
17
|
+
* stays a reader of `innerPages` after #10106 moved that read into
|
|
18
|
+
* `lib/page-chrome.ts` (#10156). A file that reads a knob under another name
|
|
19
|
+
* (a destructured alias) is not recognised — accepted (spec § Clarifications,
|
|
20
|
+
* detection).
|
|
17
21
|
*/
|
|
18
22
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
19
|
-
import { join } from "node:path";
|
|
23
|
+
import { join, posix } from "node:path";
|
|
20
24
|
import { DEFAULT_SITE_CHROME, } from "../site-chrome.js";
|
|
21
25
|
/** The overlay tree the generated app files live under. */
|
|
22
26
|
const OVERLAY_WEB_DIR = ["apps", "web"];
|
|
@@ -31,41 +35,126 @@ function listSources(root, dir, out) {
|
|
|
31
35
|
for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
|
|
32
36
|
const rel = `${dir}/${entry.name}`;
|
|
33
37
|
if (entry.isDirectory()) {
|
|
34
|
-
if (entry.name !== "__tests__"
|
|
38
|
+
if (entry.name !== "__tests__" &&
|
|
39
|
+
entry.name !== "node_modules" &&
|
|
40
|
+
!entry.name.startsWith(".")) {
|
|
35
41
|
listSources(root, rel, out);
|
|
42
|
+
}
|
|
36
43
|
}
|
|
37
44
|
else if (/\.tsx?$/.test(entry.name)) {
|
|
38
45
|
out.push(rel);
|
|
39
46
|
}
|
|
40
47
|
}
|
|
41
48
|
}
|
|
49
|
+
/** A relative module specifier in an import, export-from or dynamic import. */
|
|
50
|
+
const RELATIVE_IMPORT = /(?:\bfrom|\bimport)\s*\(?\s*["'](\.{1,2}\/[^"']+)["']/g;
|
|
51
|
+
function sourceTree(root) {
|
|
52
|
+
const texts = new Map();
|
|
53
|
+
const closures = new Map();
|
|
54
|
+
const importLists = new Map();
|
|
55
|
+
const text = (file) => {
|
|
56
|
+
if (!texts.has(file)) {
|
|
57
|
+
const abs = join(root, file);
|
|
58
|
+
texts.set(file, existsSync(abs) ? readFileSync(abs, "utf8") : null);
|
|
59
|
+
}
|
|
60
|
+
return texts.get(file);
|
|
61
|
+
};
|
|
62
|
+
/** The files `file` imports by a relative path that exist under the root; `./x.js` may name `x.ts`. */
|
|
63
|
+
const imports = (file) => {
|
|
64
|
+
const cached = importLists.get(file);
|
|
65
|
+
if (cached !== undefined)
|
|
66
|
+
return cached;
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const match of (text(file) ?? "").matchAll(RELATIVE_IMPORT)) {
|
|
69
|
+
const base = posix
|
|
70
|
+
.normalize(posix.join(posix.dirname(file), match[1]))
|
|
71
|
+
.replace(/\/$/, "");
|
|
72
|
+
if (base.startsWith(".."))
|
|
73
|
+
continue;
|
|
74
|
+
const stem = base.replace(/\.(?:js|jsx)$/, "");
|
|
75
|
+
const hit = [
|
|
76
|
+
base,
|
|
77
|
+
`${stem}.ts`,
|
|
78
|
+
`${stem}.tsx`,
|
|
79
|
+
`${stem}/index.ts`,
|
|
80
|
+
`${stem}/index.tsx`,
|
|
81
|
+
].find((candidate) => /\.tsx?$/.test(candidate) && text(candidate) !== null);
|
|
82
|
+
if (hit !== undefined)
|
|
83
|
+
out.push(hit);
|
|
84
|
+
}
|
|
85
|
+
importLists.set(file, out);
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
const reached = (file) => {
|
|
89
|
+
let closure = closures.get(file);
|
|
90
|
+
if (closure === undefined) {
|
|
91
|
+
closure = new Set();
|
|
92
|
+
const stack = [...imports(file)];
|
|
93
|
+
while (stack.length > 0) {
|
|
94
|
+
const next = stack.pop();
|
|
95
|
+
if (next === file || closure.has(next))
|
|
96
|
+
continue;
|
|
97
|
+
closure.add(next);
|
|
98
|
+
stack.push(...imports(next));
|
|
99
|
+
}
|
|
100
|
+
closures.set(file, closure);
|
|
101
|
+
}
|
|
102
|
+
return closure;
|
|
103
|
+
};
|
|
104
|
+
const readsDirectly = (file, pattern) => pattern.test(text(file) ?? "");
|
|
105
|
+
return {
|
|
106
|
+
text,
|
|
107
|
+
reached,
|
|
108
|
+
readsDirectly,
|
|
109
|
+
reads: (file, pattern) => readsDirectly(file, pattern) ||
|
|
110
|
+
[...reached(file)].some((dep) => readsDirectly(dep, pattern)),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
42
113
|
/**
|
|
43
|
-
* knob → the overlay files that read it, sorted
|
|
44
|
-
* fails closed (throws, naming the directory)
|
|
45
|
-
* so a broken overlay can never read as
|
|
114
|
+
* knob → the overlay files that read it, sorted by file; one that reads it
|
|
115
|
+
* only through relative imports carries `via`. Scans `<templatesDir>/apps/web`; fails closed (throws, naming the directory)
|
|
116
|
+
* when it is absent or unreadable, so a broken overlay can never read as
|
|
117
|
+
* "nothing reads any knob".
|
|
46
118
|
*/
|
|
47
119
|
export function chromeKnobReaders(templatesDir) {
|
|
48
120
|
const webDir = join(templatesDir, ...OVERLAY_WEB_DIR);
|
|
49
|
-
const
|
|
121
|
+
const direct = new Map();
|
|
122
|
+
const files = [];
|
|
123
|
+
const tree = sourceTree(templatesDir);
|
|
50
124
|
try {
|
|
51
125
|
if (!existsSync(webDir))
|
|
52
126
|
throw new Error("missing");
|
|
53
|
-
const files = [];
|
|
54
127
|
listSources(templatesDir, OVERLAY_WEB_DIR.join("/"), files);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
for (const match of
|
|
128
|
+
files.sort();
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
for (const match of tree
|
|
131
|
+
.text(file)
|
|
132
|
+
.matchAll(new RegExp(`${READ_HEAD}([A-Za-z_$][\\w$]*)\\b`, "g"))) {
|
|
58
133
|
const knob = match[1];
|
|
59
|
-
const list =
|
|
134
|
+
const list = direct.get(knob) ?? [];
|
|
60
135
|
if (!list.includes(file))
|
|
61
136
|
list.push(file);
|
|
62
|
-
|
|
137
|
+
direct.set(knob, list);
|
|
63
138
|
}
|
|
64
139
|
}
|
|
65
140
|
}
|
|
66
141
|
catch {
|
|
67
142
|
throw new Error(`cannot check chrome knobs: the overlay has no readable apps/web at ${webDir}. Reinstall create-ailk.`);
|
|
68
143
|
}
|
|
144
|
+
const readers = new Map();
|
|
145
|
+
for (const [knob, directFiles] of direct) {
|
|
146
|
+
const list = [];
|
|
147
|
+
for (const file of files) {
|
|
148
|
+
if (directFiles.includes(file)) {
|
|
149
|
+
list.push({ file });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const via = directFiles.filter((reader) => tree.reached(file).has(reader));
|
|
153
|
+
if (via.length > 0)
|
|
154
|
+
list.push({ file, via });
|
|
155
|
+
}
|
|
156
|
+
readers.set(knob, list);
|
|
157
|
+
}
|
|
69
158
|
return readers;
|
|
70
159
|
}
|
|
71
160
|
/** A knob is set when it differs from its default; an optional knob (no default) when present. */
|
|
@@ -81,19 +170,38 @@ function isSet(resolved, knob) {
|
|
|
81
170
|
* The `(knob, file)` pairs where the resolved chrome sets a non-default `knob`,
|
|
82
171
|
* the shipped overlay's `file` reads it, the site has its own `file`, and the
|
|
83
172
|
* site's `file` does not read it. A reader file the site lacks is skipped.
|
|
173
|
+
*
|
|
174
|
+
* A pair the overlay credits only through an import is also satisfied when
|
|
175
|
+
* some site file outside its `via` reads the knob directly: a site from before
|
|
176
|
+
* the read moved into the helper renders it in its own place (#10156). So the
|
|
177
|
+
* import credit adds a pair only for a knob the site reads nowhere but its
|
|
178
|
+
* helpers. It is per file, not per export: a site whose home page drops the
|
|
179
|
+
* helper import is named for every knob that helper reads — a refusal the
|
|
180
|
+
* operator passes with `--allow-unread-chrome`, never a silent miss.
|
|
84
181
|
*/
|
|
85
182
|
export function unreadChromeKnobs(resolved, readers, siteDir) {
|
|
86
183
|
const out = [];
|
|
184
|
+
const site = sourceTree(siteDir);
|
|
185
|
+
let siteFiles;
|
|
87
186
|
for (const knob of Object.keys(resolved).sort()) {
|
|
88
187
|
if (!isSet(resolved, knob))
|
|
89
188
|
continue;
|
|
90
189
|
const pattern = chromeReadPattern(knob);
|
|
91
|
-
for (const file of readers.get(knob) ?? []) {
|
|
92
|
-
|
|
93
|
-
if (!existsSync(abs))
|
|
190
|
+
for (const { file, via } of readers.get(knob) ?? []) {
|
|
191
|
+
if (site.text(file) === null || site.reads(file, pattern))
|
|
94
192
|
continue;
|
|
95
|
-
if (
|
|
96
|
-
|
|
193
|
+
if (via !== undefined) {
|
|
194
|
+
if (siteFiles === undefined) {
|
|
195
|
+
siteFiles = [];
|
|
196
|
+
if (existsSync(join(siteDir, ...OVERLAY_WEB_DIR))) {
|
|
197
|
+
listSources(siteDir, OVERLAY_WEB_DIR.join("/"), siteFiles);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const elsewhere = siteFiles.some((other) => !via.includes(other) && site.readsDirectly(other, pattern));
|
|
201
|
+
if (elsewhere)
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
out.push({ knob, file });
|
|
97
205
|
}
|
|
98
206
|
}
|
|
99
207
|
return out;
|
|
@@ -109,6 +109,12 @@ export interface PlanEnvRowsOptions {
|
|
|
109
109
|
* with no reader and so no `scope`; a platform or refused name is skipped.
|
|
110
110
|
*/
|
|
111
111
|
declare?: readonly string[];
|
|
112
|
+
/**
|
|
113
|
+
* When set, only reads in these site-relative files get a row (#10173):
|
|
114
|
+
* `update` declares what the files it wrote read, never a name only the
|
|
115
|
+
* site's own code reads.
|
|
116
|
+
*/
|
|
117
|
+
onlyFiles?: ReadonlySet<string>;
|
|
112
118
|
}
|
|
113
119
|
/**
|
|
114
120
|
* `projectYamlText` with a row appended for every name the site at `root`
|
package/dist/lib/declared-env.js
CHANGED
|
@@ -904,6 +904,8 @@ export function planEnvRows(root, projectYamlText, opts) {
|
|
|
904
904
|
const refused = new Set(opts.topology === "spoke" ? SPOKE_REFUSED_ENV : []);
|
|
905
905
|
const files = new Map();
|
|
906
906
|
for (const read of collectSiteReads(root).reads) {
|
|
907
|
+
if (opts.onlyFiles !== undefined && !opts.onlyFiles.has(read.file))
|
|
908
|
+
continue;
|
|
907
909
|
if (isPlatformEnvName(read.name) || refused.has(read.name))
|
|
908
910
|
continue;
|
|
909
911
|
if (declared.has(read.name))
|
|
@@ -27,3 +27,19 @@ export declare function resolveRelativeImport(fromFile: string, spec: string, ca
|
|
|
27
27
|
* @param seeds repo-relative, "/"-separated files the closure starts from.
|
|
28
28
|
*/
|
|
29
29
|
export declare function importClosure(repoRoot: string, roots: readonly string[], seeds: Iterable<string>): Set<string>;
|
|
30
|
+
/**
|
|
31
|
+
* The names a module imports from each specifier, textually (#10173): the
|
|
32
|
+
* members of a `{ … }` list (an `a as b` member names `a`, a `type a` member
|
|
33
|
+
* `a`) and `default` for a default import. A namespace import (`* as x`) and
|
|
34
|
+
* a side-effect import name nothing.
|
|
35
|
+
*/
|
|
36
|
+
export declare function namedImportsOf(text: string): Array<{
|
|
37
|
+
specifier: string;
|
|
38
|
+
names: string[];
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Whether module `text` exports `name`, textually. Fails toward `true` — an
|
|
42
|
+
* `export *`, or a destructuring export, could export anything — so a caller
|
|
43
|
+
* that holds a file back on `false` never holds one that does export it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function exportsName(text: string, name: string): boolean;
|
package/dist/lib/import-graph.js
CHANGED
|
@@ -138,3 +138,56 @@ export function importClosure(repoRoot, roots, seeds) {
|
|
|
138
138
|
}
|
|
139
139
|
return closed;
|
|
140
140
|
}
|
|
141
|
+
const IDENT = "[A-Za-z_$][\\w$]*";
|
|
142
|
+
/** `import { a, b as c }` / `export { a } from` / `import X from` — the bound names per specifier. */
|
|
143
|
+
const NAMED_IMPORT = new RegExp(`\\b(?:import|export)\\s+(?:type\\s+)?(?:(${IDENT})\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*["']([^"']+)["']` +
|
|
144
|
+
`|\\bimport\\s+(?:type\\s+)?(${IDENT})\\s+from\\s*["']([^"']+)["']`, "g");
|
|
145
|
+
/**
|
|
146
|
+
* The names a module imports from each specifier, textually (#10173): the
|
|
147
|
+
* members of a `{ … }` list (an `a as b` member names `a`, a `type a` member
|
|
148
|
+
* `a`) and `default` for a default import. A namespace import (`* as x`) and
|
|
149
|
+
* a side-effect import name nothing.
|
|
150
|
+
*/
|
|
151
|
+
export function namedImportsOf(text) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const m of text.matchAll(NAMED_IMPORT)) {
|
|
154
|
+
if (m[3] !== undefined) {
|
|
155
|
+
const names = (m[2] ?? "")
|
|
156
|
+
.split(",")
|
|
157
|
+
.map((member) => member.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim())
|
|
158
|
+
.filter((name) => new RegExp(`^${IDENT}$`).test(name));
|
|
159
|
+
if (m[1] !== undefined)
|
|
160
|
+
names.push("default");
|
|
161
|
+
out.push({ specifier: m[3], names });
|
|
162
|
+
}
|
|
163
|
+
else if (m[5] !== undefined) {
|
|
164
|
+
out.push({ specifier: m[5], names: ["default"] });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Whether module `text` exports `name`, textually. Fails toward `true` — an
|
|
171
|
+
* `export *`, or a destructuring export, could export anything — so a caller
|
|
172
|
+
* that holds a file back on `false` never holds one that does export it.
|
|
173
|
+
*/
|
|
174
|
+
export function exportsName(text, name) {
|
|
175
|
+
if (/\bexport\s*\*/.test(text))
|
|
176
|
+
return true;
|
|
177
|
+
if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(text))
|
|
178
|
+
return true;
|
|
179
|
+
if (name === "default" && /\bexport\s+default\b/.test(text))
|
|
180
|
+
return true;
|
|
181
|
+
const declared = new RegExp(`\\bexport\\s+(?:declare\\s+)?(?:async\\s+)?(?:abstract\\s+)?` +
|
|
182
|
+
`(?:const\\s+enum|const|let|var|function\\s*\\*?|class|type|interface|enum|namespace)\\s*${name.replace(/\$/g, "\\$")}\\b`);
|
|
183
|
+
if (declared.test(text))
|
|
184
|
+
return true;
|
|
185
|
+
for (const m of text.matchAll(/\bexport\s*(?:type\s*)?\{([^}]*)\}/g)) {
|
|
186
|
+
for (const member of (m[1] ?? "").split(",")) {
|
|
187
|
+
const parts = member.trim().replace(/^type\s+/, "").split(/\s+as\s+/);
|
|
188
|
+
if ((parts[1] ?? parts[0])?.trim() === name)
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|