diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c9209c5b..87047104 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -42,7 +42,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck - - run: pnpm -r --filter '!./dapp/daml' run --if-present build + - run: pnpm -r run --if-present build - run: pnpm knip - run: pnpm docs:check - run: pnpm run check:anatomy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14c403da..c2ba0465 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: Verify the tag matches the manifests env: TAG: ${{ github.event.release.tag_name }} - run: node kit/check-versions.mjs "${TAG#v}" + run: node scripts/check-versions.mjs "${TAG#v}" - run: pnpm lint - run: pnpm typecheck - run: pnpm test diff --git a/.gitignore b/.gitignore index 637bfbf9..34dab813 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ typedoc/ # canton-barebones: its scaffolded config, the Splice checkout and the runtime env .canton-localnet/ +# dev-stack.sh: logs of the background processes it starts +.dev-stack/ + # wallet-gateway: its sqlite wallet and signing stores .wallet-gateway/ @@ -47,11 +50,15 @@ docs/ **/.playwright-mcp/ test-results/ playwright-report/ -# stray captures land at the repo root; no tracked image belongs there -/*.png # brainstorming visual companion .superpowers/ # vitest coverage output coverage/ + +# canton-create: the sandbox try.sh installs the packed CLI into +canton-create/.try/ +# the DAML starter's build output and fetched DARs +canton-create/scaffold/starter/daml/.daml/ +canton-create/scaffold/starter/daml/deps/ diff --git a/.lintstagedrc.format.mjs b/.lintstagedrc.format.mjs index a7932b3f..00499641 100644 --- a/.lintstagedrc.format.mjs +++ b/.lintstagedrc.format.mjs @@ -1,6 +1,6 @@ // The writing half of the pre-commit, kept apart so the read-only gates in .lintstagedrc.mjs can // run concurrently behind it. See .husky/pre-commit. export default { - '{canton-connect,canton-dappbooster,canton-theme,dapp/frontend,kit,scripts}/**/*.{ts,tsx,js,jsx,json,jsonc,mjs,cjs,css}': + '{canton-connect,canton-dappbooster,canton-theme,example-dapps,scripts,canton-create}/**/*.{ts,tsx,js,jsx,json,jsonc,mjs,cjs,css}': 'biome check --write --no-errors-on-unmatched', } diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 676a0182..0e702fcf 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,15 +1,15 @@ -// Read-only gates only. The formatter runs first, from .lintstagedrc.format.mjs, so these are safe -// to run concurrently with each other. export default { 'canton-connect/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C canton-connect test', 'canton-dappbooster/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C canton-dappbooster test', - 'dapp/frontend/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C dapp/frontend test', - // One task for the whole doc gate, under the name CI uses: typedoc resolves both packages in a - // single run anyway, so splitting it per package bought a second and two ways to drift. - // Snippets live in the READMEs as well as in the doc blocks, so a doc edit re-runs the compile. + 'example-dapps/*/src/**/*.{ts,tsx,js,jsx}': (files) => + [...new Set(files.map((file) => file.match(/example-dapps\/[^/]+/)?.[0]))].map( + (example) => `pnpm -C ${example} test`, + ), + 'canton-create/**/*.{ts,tsx,mjs,sh,json}': () => 'pnpm -C canton-create test', + '{canton-create/scaffold/*/scripts,scripts}/*.{mjs,sh}': () => + 'node --test "canton-create/scaffold/*/scripts/*.test.mjs" "scripts/*.test.mjs"', '{canton-connect,canton-dappbooster}/{src/**/*.{ts,tsx},doc-fixtures.d.ts,README.md,architecture.md,coming-from-wagmi.md,typedoc.json}': () => 'pnpm docs:check', - // Either side of the L2/L3 contract moving on its own is what this catches. '{canton-dappbooster/src/{components,providers}/**/anatomy.ts,canton-theme/src/**/*.css}': () => 'pnpm run check:anatomy', } diff --git a/CLAUDE.md b/CLAUDE.md index 0d41bb5c..1f3fed4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,16 +1,16 @@ -# Agent Configuration — Canton dAppBooster +# Agent configuration for Canton dAppBooster This file is the canonical monorepo-wide agent configuration. `AGENTS.md` files are compatibility shims that point here or to a sibling `CLAUDE.md`. Each subproject can layer its own `CLAUDE.md` for stack-specific deltas: -- [`canton-connect/CLAUDE.md`](canton-connect/CLAUDE.md) — wagmi-style React hooks for Canton dApps -- [`canton-dappbooster/CLAUDE.md`](canton-dappbooster/CLAUDE.md) — L2 component authoring and file layout -- [`canton-theme/CLAUDE.md`](canton-theme/CLAUDE.md) — L3 `--cnc-*` token naming convention -- [`dapp/frontend/CLAUDE.md`](dapp/frontend/CLAUDE.md) — app layout and naming deltas; its seams are in [`dapp/frontend/architecture.md`](dapp/frontend/architecture.md) -- `dapp/daml/` — see its `README.md` +- [`canton-connect/CLAUDE.md`](canton-connect/CLAUDE.md): wagmi-style React hooks for Canton dApps +- [`canton-create/CLAUDE.md`](canton-create/CLAUDE.md): the `create-canton-dappbooster` installer +- [`canton-dappbooster/CLAUDE.md`](canton-dappbooster/CLAUDE.md): L2 component authoring and file layout +- [`canton-theme/CLAUDE.md`](canton-theme/CLAUDE.md): the L3 `--cnc-*` token naming convention +- [`example-dapps/amulet-vesting/CLAUDE.md`](example-dapps/amulet-vesting/CLAUDE.md): the vesting example's layout and naming deltas. Its seams are in [`example-dapps/amulet-vesting/architecture.md`](example-dapps/amulet-vesting/architecture.md), and its Daml in `daml/README.md` The dApp connects through any CIP-0103 wallet; no wallet lives in this monorepo. @@ -18,97 +18,97 @@ For the system shape (data flow, components, ports), see [`architecture.md`](arc --- -## Documentation Distribution +## Documentation distribution Use one reader per doc type, layered by scope: | File | Reader / question | Distribution rule | |------|-------------------|-------------------| -| `README.md` | Human: what is this and how do I run it? | Every independently buildable, runnable, publishable, or testable unit gets one. Subproject READMEs cover only that unit and link to the root README for shared setup. | -| `CLAUDE.md` | Agent: what local rules change how I edit here? | Root always. Subproject only when local conventions differ from root enough that an agent editing only that directory would get it wrong. Deltas only; link upward for repo-wide rules. | +| `README.md` | Human: what is this and how does someone run it? | Every unit that builds, runs, publishes or tests on its own gets one. A subproject's README covers only that unit and links to the root README for shared setup. | +| `CLAUDE.md` | Agent: what local rules change how an agent edits here? | Root always. Subproject only when local conventions differ from root enough that an agent editing only that directory would get it wrong. Deltas only; link upward for repo-wide rules. | | `AGENTS.md` | Agent compatibility loader | Three-line shim beside every `CLAUDE.md`, pointing to the sibling `CLAUDE.md`. It is never canonical. | -| `architecture.md` | Human or agent: what are the structural seams and internal subsystems? | Root always for cross-component seams. Subproject only when internals outgrow the README: three or more interacting subsystems, non-trivial control flow, or named abstractions. | -| `architecture/.md` | Human or agent editing one subsystem: how does it behave in full? | Only beside an `architecture.md` that indexes it, when a section outgrows the seam it describes. The index keeps the seam and links the chapter; no chapter without its index entry. | -| Generated reference | Human: what does this export do and how do I call it? | Not hand-maintained and not a file anyone edits. `kit/typedoc.json` builds it from the JSDoc on `canton-dappbooster` and `canton-connect`'s barrels; `@internal` keeps a symbol off it. Fix the doc block, never the site. `canton-connect/coming-from-wagmi.md`, published through `projectDocuments`, is the one hand-written page in it: an exception for a mapping to another library's API, not a pattern to repeat. | +| `architecture.md` | Human or agent: what are the structural seams and internal subsystems? | Root always for cross-component seams. Subproject only when internals outgrow the README: three or more interacting subsystems, control flow that takes explaining, or named abstractions. | +| `architecture/.md` | Human or agent editing one subsystem: how does it behave in full? | Only beside an `architecture.md` that indexes it, when a section outgrows the seam it describes. The index keeps the seam and links the page; no page without its index entry. | +| Generated reference | Human: what does this export do and how does someone call it? | Not hand-maintained and not a file anyone edits. `scripts/typedoc.json` builds it from the JSDoc on the barrels of `canton-dappbooster` and `canton-connect`, and `@internal` keeps a symbol off it. Fix the doc block, never the site. `canton-connect/coming-from-wagmi.md`, published through `projectDocuments`, is the one hand-written page in it: an exception for a mapping to another library's API, not a pattern to repeat. | Current distribution: | Scope | README | AGENTS | CLAUDE | architecture | Decision | |-------|--------|--------|--------|--------------|----------| | root | yes | shim | yes | yes | Canonical repo rules and cross-component seams. | -| `canton-connect/` | yes | shim | yes | yes, plus `architecture/` | Public hook API, the machine-owned lifecycle, the picker/adapter seams; chapters for the connection machine and the popup close guard. | -| `dapp/frontend/` | yes | shim | yes | yes | Canton Coin vesting dApp; `CLAUDE.md` carries the page-owns-its-components layout and the naming rules an agent would otherwise get wrong, architecture.md its internal seams. Carries a `PROVENANCE.md` recording the vendored source. | -| `dapp/daml/` | yes | no | no | no | Single DAML package (`amulet-vesting`), vendored source, built here. Carries a `PROVENANCE.md` recording the source commit and the two integration deltas. | +| `canton-connect/` | yes | shim | yes | yes, plus `architecture/` | Public hook API, the machine-owned lifecycle, the picker/adapter seams; pages for the connection machine and the popup close guard. | +| `canton-create/` | yes | shim | yes | yes | The `create-canton-dappbooster` installer. `CLAUDE.md` carries the flag rule, the scaffold rules and the demo recording, and `architecture.md` how a run and the scaffold layers fit together. A project reads the `README.md` files in `scaffold/`: `starter/`'s whole, and `daml-tooling/`'s and `localnet/`'s as sections appended to the app's. | +| `example-dapps/amulet-vesting/` | yes | shim | yes | yes | Canton Coin vesting example, published on its own. `CLAUDE.md` carries the page-owns-its-components layout and the naming rules an agent would otherwise get wrong, and architecture.md its internal seams. Its single Daml package (`amulet-vesting`) sits in `daml/` with a `README.md` of its own. `PROVENANCE.md` and `daml/PROVENANCE.md` record the vendored sources. | | `canton-dappbooster/` | yes | shim | yes | yes | L2 headless components; `CLAUDE.md` carries the folder-per-component layout an agent would otherwise get wrong, architecture.md the authoring seam (anatomy contract, L2/L3 split, Zag boundary). | | `canton-theme/` | yes | shim | yes | no | Plain-CSS theme (L3); README covers the two CSS exports, `CLAUDE.md` the `--cnc-*` naming convention an agent adding a token would otherwise invent. | Subproject docs must not restate root rules. They should describe only their local delta and link upward. -**Reference material never goes in a README.** A README answers "what is this and how do I run it". +**Reference material never goes in a README.** A README answers "what is this and how does someone run it". Enumerations belong elsewhere however short they are: token lists, prop tables, exported-symbol indexes, config keys. -- When the code *is* the list — a token file, a type, a barrel — the code is the doc. Group it with +- When the code *is* the list (a token file, a type, a barrel), the code is the doc. Group it with category comments; a prose copy drifts within two PRs. - A rule constraining how to author the code goes in the nearest `CLAUDE.md`. - A seam between units goes in `architecture.md`. A README may state that a contract exists and link to it. It may not restate it. -## Stack & Conventions (monorepo) +## Stack and conventions (monorepo) | Category | Technology | Notes | |----------|-----------|-------| -| Languages | TypeScript, DAML, Bash | TypeScript across the JS subprojects; DAML in `dapp/daml/`; Bash and Node for the root `scripts/` and `kit/` | +| Languages | TypeScript, Daml, Bash | TypeScript across the JS subprojects; Daml in each app's `daml/`; Bash and Node for the root `scripts/` and the scaffold's | | Package manager | pnpm workspaces | Single root `pnpm-lock.yaml`; one root `pnpm install` links every workspace. Workspace layout, `linkWorkspacePackages` and `allowBuilds` live in `pnpm-workspace.yaml`. Root `package.json` orchestrates scripts via `pnpm -C ` | -| Node | 24 | Exact version pinned via root `.nvmrc`; inherits to every Node subproject. Root and the four Node subprojects all declare `engines.node` at `>=24.15.0`, which is what jsdom 30 requires | +| Node | 24 | Exact version pinned via root `.nvmrc`; inherits to every Node package. Root, `canton-connect`, `canton-dappbooster` and `example-dapps/amulet-vesting` declare `engines.node` at `>=24.15.0`, which is what `jsdom` 30 requires. `canton-create` declares `>=22.12.0` | | Container runtime | Docker | Required by the `@bootnodedev/canton-barebones` LocalNet; nothing in this repository builds an image | -| Wallet | @canton-network/wallet-gateway-remote | The CIP-0103 wallet the dApp connects to, from the Splice wallet kernel. Pinned exact in root devDependencies, run by `pnpm run wallet-gateway` on port 3030, configured by the committed `wallet-gateway.config.json`. `dapp/frontend` registers it as a `RemoteAdapter` in `additionalAdapters`, which is what lets a session survive a reload | -| LocalNet | @bootnodedev/canton-barebones | Pinned exact in root devDependencies and reached through `pnpm exec canton-barebones`, so the version is the one in `package.json`. Nothing about its config is committed: `scripts/localnet-config.mjs` scaffolds the gitignored `.canton-localnet/` from the tool's own template and turns on `validators.appUser.ui` and `sv.scanUI`, without which nginx serves no `/api/validator` or `/api/scan`. The Splice checkout and the runtime env land in `.canton-localnet/.generated/` | -| Commit linting | commitlint + husky | Enforced via root `.husky/commit-msg` | -| Lint / format | Biome | One root `biome.json` and a single root `@biomejs/biome`; per-project specifics live in `overrides`. No per-subproject Biome install or config. `pnpm lint` = `biome check --error-on-warnings` (warnings fail); standalone SVG assets are excluded | -| Pre-commit | lint-staged | Two passes from `.husky/pre-commit`, because only the first writes: `.lintstagedrc.format.mjs` runs root Biome (`biome check --write`) across `canton-connect/`, `canton-dappbooster/`, `canton-theme/`, `dapp/frontend/`, `kit/` and `scripts/`, then `.lintstagedrc.mjs` runs the read-only gates — the tests, the doc check and the anatomy check — concurrently. One pass would let a reformat land mid-parse | -| Pre-push | tsc | Root `.husky/pre-push` runs `pnpm typecheck` (`pnpm -r run --if-present typecheck`, i.e. `tsc` in each Node subproject that defines it) | -| Secret scanning | gitleaks | Shared `.husky/gitleaks.sh` runs gitleaks in the pre-commit (staged diff) and pre-push (outgoing range) hooks; the pinned version (`.gitleaks-version`) is installed by `scripts/install-gitleaks.sh`, so local and CI use the same rules. Accepted non-secret findings live in `.gitleaksignore` | -| Dead code | knip | Root `knip.json` + `pnpm knip`; gates unused files/dependencies/exports. `postcss` is in `ignoreDependencies` for the reason under `kit/` and the consumer scaffold | -| Doc reference + gate | typedoc | `kit/typedoc.json` over `canton-dappbooster` and `canton-connect`, each declaring its entry points in its own `typedoc.json` and extending `kit/typedoc.shared.json` for every option that resolves per package. `pnpm docs:check` validates without emitting; `pnpm docs:build` writes the site to `typedoc/`. One config for both, strict: every validation on, `treatValidationWarningsAsErrors` and `treatWarningsAsErrors` | -| Doc rules gate | `kit/docs-check.mjs` | `pnpm docs:check` runs it after typedoc. Owns what typedoc cannot see: barrel completeness, `@example` presence and naming by tier, snippet compilation, comment width, tier caps, `@category` values, the `@throws` and anatomy-`@see` requirements, the `@param`/`@returns` refusals, and description presence on exported functions (see the splits below) | -| Anatomy parity gate | `kit/check-anatomy.mjs` | `pnpm check:anatomy` checks every class and `data-*` selector in `canton-theme` against the `anatomy.parts.*` / `anatomy.states.*` strings in `canton-dappbooster`, and requires each anatomy to be reached by at least one selector. Asymmetric on purpose, for the reason its header gives: an unstyled part is a legitimate consumer hook, so there is no per-part check the other way. `aria-*` states are outside it. A styling gate, not a doc one | -| Version lockstep check | `kit/check-versions.mjs` | `pnpm run check:versions` fails unless every declared range pointing at a library is `^`. `kit/check-versions.test.mjs` runs the same check inside `pnpm test`, and the PR job calls the script. Given a version argument it also requires the root and the three libraries to be on it, which is how the release workflow refuses a tag that disagrees with the manifests | -| Reference site | Vercel | Project `docs.canton-dappbooster` under the BootNode team, production branch `main`, built by the git integration from `pnpm docs:build`. Its root directory is the repo root, so the root `vercel.json` is its build settings and nobody else's | -| Demo deployment | Vercel | Project `demo.canton-dappbooster` under the same team, root directory `dapp/frontend`, so it reads `dapp/frontend/vercel.json`. A project resolves `vercel.json` relative to its own root directory, which is what keeps the two from colliding. `sourceFilesOutsideRootDirectory` is on and the build command runs from the workspace root, because a production build resolves both libraries to their `dist` rather than their source. Git-connected, production branch `main`, so a merge deploys and a branch gets a preview. The deploy is a static bundle, so `vercel.json` rewrites every path to `index.html` | -| CI | GitHub Actions | `.github/workflows/pr.yml` gate on every PR (biome, typecheck+build+knip+docs, test, commitlint, gitleaks). `main` is protected: 1 approval + all checks green. `.github/workflows/release.yml` publishes to npm when a GitHub release is published; see Packaging And Publishing. `add-to-project` and `pr-assign` automate the board and PR assignee | -| Dependency updates | Renovate | `renovate.json`: non-major updates batched weekly, no auto-merge; the `@canton-network/*` SDK graph is held for manual approval on the Dependency Dashboard | +| Wallet | `@canton-network/wallet-gateway-remote` | The CIP-0103 wallet the dApp connects to, from the Splice wallet kernel. Pinned exact in the root `devDependencies`, run by `pnpm run wallet-gateway` on port 3030, configured by the committed `canton-create/scaffold/localnet/wallet-gateway.config.json`, the file every project with a local network gets. `example-dapps/amulet-vesting` registers it as a `RemoteAdapter` in `additionalAdapters`, which is what lets a session survive a reload | +| LocalNet | `@bootnodedev/canton-barebones` | Pinned exact in `canton-create/scaffold/localnet/package.json`, the only manifest that declares it. `publicHoistPattern` in `pnpm-workspace.yaml` links it and its command into the root `node_modules`, so `pnpm exec canton-barebones` and the scaffold's scripts reach that one copy from the root. The repo commits none of its config. `canton-create/scaffold/localnet/scripts/localnet-config.mjs` scaffolds the gitignored `.canton-localnet/` from the tool's own template and turns on `validators.appUser.ui` and `sv.scanUI`. Without them, nginx serves no `/api/validator` or `/api/scan`. The Splice checkout and the runtime env land in `.canton-localnet/.generated/` | +| Commit linting | commitlint + Husky | Enforced via root `.husky/commit-msg` | +| Lint / format | Biome | One root `biome.json` and a single root `@biomejs/biome`; per-project specifics live in `overrides`. No per-subproject Biome install or config. `pnpm lint` = `biome check --error-on-warnings` (warnings fail); it skips standalone SVG assets | +| Pre-commit | lint-staged | Two passes from `.husky/pre-commit`, because only the first writes. `.lintstagedrc.format.mjs` runs root Biome (`biome check --write`) across `canton-connect/`, `canton-create/`, `canton-dappbooster/`, `canton-theme/`, `example-dapps/` and `scripts/`. Then `.lintstagedrc.mjs` runs the read-only gates (the tests, the doc check and the anatomy check) concurrently. One pass would let a reformat land mid-parse | +| Pre-push | `tsc` | Root `.husky/pre-push` runs `pnpm typecheck` (`pnpm -r run --if-present typecheck`, that is `tsc` in each Node subproject that defines it) | +| Secret scanning | gitleaks | Shared `.husky/gitleaks.sh` runs gitleaks in the pre-commit (staged diff) and pre-push (outgoing range) hooks. `scripts/install-gitleaks.sh` installs the pinned version (`.gitleaks-version`), so local and CI use the same rules. Accepted non-secret findings live in `.gitleaksignore` | +| Dead code | knip | Root `knip.json` + `pnpm knip`; gates unused files/dependencies/exports | +| Doc reference + gate | TypeDoc | `scripts/typedoc.json` covers `canton-dappbooster` and `canton-connect`. Each declares its entry points in its own `typedoc.json` and extends `scripts/typedoc.shared.json` for every option that resolves per package. `pnpm docs:check` validates without emitting; `pnpm docs:build` writes the site to `typedoc/`. One config for both, strict: every validation on, `treatValidationWarningsAsErrors` and `treatWarningsAsErrors` | +| Doc rules gate | `scripts/docs-check.mjs` | `pnpm docs:check` runs it after TypeDoc. It owns what TypeDoc cannot see: barrel completeness, `@example` presence and naming by tier, snippet compilation, comment width, tier caps and `@category` values. It also owns the `@throws` and anatomy-`@see` requirements, the `@param`/`@returns` refusals, and description presence on exported functions (see the splits below) | +| Anatomy parity gate | `scripts/check-anatomy.mjs` | `pnpm check:anatomy` checks every class and `data-*` selector in `canton-theme` against the `anatomy.parts.*` / `anatomy.states.*` strings in `canton-dappbooster`. It also requires at least one selector to reach each anatomy. It is asymmetric on purpose, for the reason its header gives. A part without styles is a legitimate consumer hook, so there is no per-part check the other way. `aria-*` states are outside it. A styling gate, not a doc one | +| Version lockstep check | `scripts/check-versions.mjs` | `pnpm run check:versions` fails unless every declared range pointing at a library is `^`. `scripts/check-versions.test.mjs` runs the same check inside `pnpm test`, and the PR job calls the script. Given a version argument, it also requires the root and every public package to be on it. That is how the release workflow refuses a tag that disagrees with the manifests | +| Reference site | Vercel | Project `docs.canton-dappbooster` under the BootNode team, production branch `main`, built by the git integration from `pnpm docs:build`. Its root directory is the repo root, so the root `vercel.json` holds its build settings and no other project's | +| Demo deployment | Vercel | Project `demo.canton-dappbooster` under the same team, root directory `example-dapps/amulet-vesting`, so it reads `example-dapps/amulet-vesting/vercel.json`. A project resolves `vercel.json` relative to its own root directory, which is what keeps the two from colliding. `sourceFilesOutsideRootDirectory` is on and the build command runs from the workspace root. A production build needs both, because it resolves both libraries to their `dist` rather than their source. Git-connected, production branch `demo-canton-dappbooster-cc`, so a push there deploys and every other branch gets a preview. The deploy is a static bundle, so `vercel.json` rewrites every path to `index.html` | +| CI | GitHub Actions | `.github/workflows/pr.yml` gate on every PR (Biome, typecheck+build+knip+docs, test, commitlint, gitleaks). Branch protection on `main` requires 1 approval and every check green. `.github/workflows/release.yml` publishes to npm when someone publishes a GitHub release; see Packaging and publishing. `add-to-project` and `pr-assign` automate the board and PR assignee | +| Dependency updates | Renovate | `renovate.json`: non-major updates batched weekly, no auto-merge. Renovate holds the `@canton-network/*` SDK graph for manual approval on the Dependency Dashboard | ## Subprojects | Path | Purpose | Stack | Port | |------|---------|-------|------| -| [`dapp/daml/`](dapp/daml/) | `amulet-vesting` DAML model: factory, proposal, contract, residual claim, escrowing Canton Coin as a Splice `LockedAmulet`. Vendored from [cc-vesting-contracts](https://github.com/BootNodeDev/cc-vesting-contracts), where its scenarios stay | DAML | n/a (DAR artifact) | -| [`dapp/frontend/`](dapp/frontend/) | Canton Coin vesting dApp over the local participant. Every read and write goes through the connected CIP-0103 wallet via `canton-connect`; the operator's factory, the `AmuletRules` and the open mining round all arrive by explicit disclosure. Imported from `cn-dappbooster@feat/vesting-lite` (see its `PROVENANCE.md`). | Vite + React + Ark UI + lucide-react + Tailwind v4 + zustand + react-router + Biome | 3012 | -| [`canton-connect/`](canton-connect/) | wagmi-style React hooks wrapping the `dapp-sdk` facade; the SDK owns discovery, the picker, the session and the transports | TypeScript + React 19 + xstate 5 + Biome | n/a (library) | -| [`canton-dappbooster/`](canton-dappbooster/) | L2 headless UI components for Canton dApps (tsdown-built, zero styling), plus the light/dark/system theme runtime that drives `data-theme`, plus the pure utilities the components are built on, the exact-decimal amount ones included. Styling lives in `canton-theme`. `src/index.ts` is the public API; `src/connect.ts` is the `/connect` sub-path, holding the components that read the wallet session so the main barrel stays free of the Canton SDK. | TypeScript + React 19 + tsdown + vitest + Biome | n/a (library) | -| [`canton-theme/`](canton-theme/) | L3 plain-CSS theme for the kit: `--cnc-*` tokens + prestyled defaults, consumed by importing its CSS. | CSS | n/a (library) | +| [`example-dapps/amulet-vesting/`](example-dapps/amulet-vesting/) | Canton Coin vesting dApp, an example `create-canton-dappbooster` scaffolds, published as `@bootnodedev/canton-example-amulet-vesting`. Every read and write goes through the connected CIP-0103 wallet via `canton-connect`; the operator's factory, the `AmuletRules` and the open mining round all arrive by explicit disclosure. Its `amulet-vesting` Daml model in `daml/` covers the factory, the proposal, the contract and the residual claim, and holds Canton Coin in escrow as a Splice `LockedAmulet`. It is vendored from [cc-vesting-contracts](https://github.com/BootNodeDev/cc-vesting-contracts), where its scenarios stay. Imported from `cn-dappbooster@feat/vesting-lite` (see its `PROVENANCE.md`). | Vite + React + Ark UI + lucide-react + Tailwind v4 + `zustand` + react-router + Daml | 3012 | +| [`canton-create/`](canton-create/) | The `create-canton-dappbooster` installer, published on its own. [`canton-create/architecture.md`](canton-create/architecture.md) covers how it builds a project. | TypeScript + Ink + React 19 + `tsdown` + `vitest` | n/a (command-line tool) | +| [`canton-connect/`](canton-connect/) | wagmi-style React hooks wrapping the `dapp-sdk` facade; the SDK owns discovery, the picker, the session and the transports | TypeScript + React 19 + `xstate` 5 + Biome | n/a (library) | +| [`canton-dappbooster/`](canton-dappbooster/) | L2 headless UI components for Canton dApps, built with `tsdown` and carrying no styling. It also holds the light/dark/system theme runtime that drives `data-theme`, and the pure utilities the components build on, the exact-decimal amount ones included. Styling lives in `canton-theme`. `src/index.ts` is the public API. `src/connect.ts` is the `/connect` sub-path, holding the components that read the wallet session so the main barrel stays free of the Canton SDK. | TypeScript + React 19 + `tsdown` + `vitest` + Biome | n/a (library) | +| [`canton-theme/`](canton-theme/) | L3 plain-CSS theme for the kit: `--cnc-*` tokens + styled defaults, consumed by importing its CSS. | CSS | n/a (library) | Two things the loop needs are not subprojects but dependencies. The LocalNet ships from -[BootNodeDev/canton-barebones](https://github.com/BootNodeDev/canton-barebones), is a pinned -devDependency whose config `scripts/dev-stack.sh` scaffolds into the gitignored -`.canton-localnet/` and drives there over `pnpm exec`. The wallet ships from -[canton-network/wallet](https://github.com/canton-network/wallet) as -`@canton-network/wallet-gateway-remote`, installs from npm as a root devDependency, and -`scripts/dev-stack.sh` runs it on port 3030 through `pnpm run wallet-gateway`. +[`BootNodeDev/canton-barebones`](https://github.com/BootNodeDev/canton-barebones) as a dev +dependency pinned in `canton-create/scaffold/localnet/`. `pnpm stack` scaffolds its config into +the gitignored `.canton-localnet/` and drives it there. The wallet ships from +[`canton-network/wallet`](https://github.com/canton-network/wallet) as +`@canton-network/wallet-gateway-remote` and installs from npm as a root dev dependency. +`pnpm stack` runs it on port 3030 through `pnpm run wallet-gateway`. -## Code Style +## Code style - All source code in English regardless of conversation language. - TypeScript preferred over JavaScript across Node subprojects. - **No semicolons** in TypeScript / JavaScript across the repo. -- **Name it, do not caption it.** A function, variable or type that needs a comment to be understood - gets renamed or split instead. The name is the documentation. -- **A comment is only for what the code cannot carry**: a hack, a workaround, an outside constraint - (browser bug, protocol quirk, an ordering that matters), a deliberate *omission*, or a rejected - alternative. Nothing else earns one. -- **One line, hard cap**, on the line it applies to and not in a block above the function. Needing a +- **Name it, do not caption it.** A function, variable or type that a reader cannot follow without a + comment gets renamed or split instead. The name is the documentation. +- **A comment is only for what the code cannot carry.** That means a hack, a workaround, or an + outside constraint (browser bug, protocol quirk, an ordering that matters). It also means a + deliberate *omission* or a rejected alternative. Nothing else gets one. +- **One line, hard cap**, on the line it applies to and not in a block before the function. Needing a second line means the code is wrong or the prose belongs in an `.md`. - **No narration.** Never restate what the code says, walk through steps, or explain how a dependency behaves. A file carrying more comment than code is a bad file. @@ -119,35 +119,35 @@ devDependency whose config `scripts/dev-stack.sh` scaffolds into the gitignored stays allowed. - **CSS carries no comments at all, with one exception: a section separator** naming the block that follows (`/* Account popover */`, `/* Token chips */`, `/* Colour roles */`). Nothing else, not - even the exception above: a stylesheet workaround or ordering constraint is recorded in the - nearest `CLAUDE.md`, where the next author looks before editing, and not in a comment they will - delete. + even the exception named earlier. Record a style sheet workaround or ordering constraint in the + nearest `CLAUDE.md`, where the next author looks before editing. A comment there would get + deleted. - **Where the prose goes instead**: how a subsystem behaves in the nearest `architecture.md`, a rule about how to write the code in the nearest `CLAUDE.md`. Both outlive a comment. -- **Fix a comment you touch.** Find one breaking these rules in a file the current task already - changes and fix it in the same change: delete it where the code carries the fact, cut it to one - line where it does not, or move the prose to the nearest `.md`. Before deleting, check the code - still carries the fact — an absence and a road not taken never do. In any other file, name it in - your report and leave it alone; a repo-wide sweep is its own commit. -- JSDoc is exempt from the one-line cap but not from terseness: say what the symbol does, and when a +- **Fix a comment in a file you change.** Find one breaking these rules in a file the current task + already changes, and fix it in the same change. Delete it where the code carries the fact, cut it + to one line where it does not, or move the prose to the nearest `.md`. Before deleting, check the + code still carries the fact: an absence and a rejected option never do. In any other file, name it + in your report and leave it alone; a repo-wide sweep is its own commit. +- JSDoc is exempt from the one-line cap but not from terseness. Say what the symbol does, and when a caller could reasonably pick a different export, say which. Never restate the type, never inventory the fields. Every JSDoc block carries at least one `@example` showing real usage, and the Doc blocks table below caps the rest. -- Lint and formatting are centralized in the root `biome.json`. Add project-specific rules under `overrides` keyed by path; do not create per-subproject Biome configs. +- The root `biome.json` holds lint and formatting. Add project-specific rules under `overrides` keyed by path; do not create per-subproject Biome configs. -## File & Folder Organization +## File and folder organization Applies to every TypeScript subproject. Biome (`biome.json`) enforces the allowed casings, the `use` -prefix inside any `src/hooks/`, one-export-per-file naming inside any `src/icons/`, extensionless -imports, the alias-only import rule below, and the `testing/` boundary. Which of the allowed casings -a given file takes is convention: -a linter reads basenames, so it cannot tell a component from a multi-export collection, and folder -casing it cannot see at all. +prefix inside any `src/hooks/`, and one-export-per-file naming inside any `src/icons/`. It also +enforces imports without an extension, the alias-only import rule below, and the `testing/` +boundary. Which of the allowed casings a given file takes is convention. A linter reads file names, +so it cannot tell a component from a multi-export collection, and folder casing it cannot see at +all. | Kind | Casing | Example | |------|--------|---------| | React component | PascalCase, matching the export | `Button.tsx`, `CopyIcon.tsx` | -| Class or instantiable module | PascalCase | `LedgerBackend.ts` | +| Class, or a module you instantiate | PascalCase | `LedgerBackend.ts` | | Hook | camelCase, `use`-prefixed | `useCopyToClipboard.ts` | | Plain module or helper | camelCase | `truncate.ts`, `format.ts` | | Multi-export leaf collection | camelCase plural | `icons.tsx`, `hooks.ts` | @@ -157,325 +157,314 @@ A component's own test keeps the component name (`Identifier.test.tsx`), not the Placement: -- Colocate by default. A module used by one component lives beside it; promote it only when a second - consumer appears. -- Promoted code goes in a kind folder at the src root (`components/`, `hooks/`, `icons/`, +- Keep a module beside its one consumer by default. Promote it only when a second consumer appears. +- Promoted code goes in a kind folder at the root of `src` (`components/`, `hooks/`, `icons/`, `providers/`, `testing/`, `utils/`). Those exist from their first member. `utils/` is the one that - never rejects a file, so each of its modules is named for what it holds (`partyId.ts`, `cx.ts`), - never `helpers.ts` or an `index.ts` barrel. + never rejects a file, so each of its modules takes its name from what it holds (`partyId.ts`, + `cx.ts`), never `helpers.ts` or an `index.ts` barrel. - Components live in `components/`, which is a kind folder like the rest and gets no special case. Routed pages are the one thing kept apart, in `pages/`, because the router enters them rather than a parent composing them. A page is a consumer like any other, so what only one page renders lives beside it. - A component whose job is to supply context rather than render markup lives in `providers/`, named - `Provider`, so what wraps the tree is one place to look instead of a hunt through feature - folders. + `Provider`. What wraps the tree is then one place to look instead of a hunt through + feature folders. - A leaf collection (`icons.tsx`, `hooks.ts`) holds same-kind exports beside their one consumer. Promoting it to a kind folder splits it into one file per export, named after that export. - Never a folder wrapping a single module. A one-file component stays a flat file (`components/Button.tsx`) and earns a folder only when it outgrows one file. -- A component folder is PascalCase, its entry is `index.tsx`, and its subcomponents are PascalCase - files beside it. +- A component folder is PascalCase, its entry is `index.tsx`, and its child components are + PascalCase files beside it. - `testing/` holds test-only helpers and doubles, never imported from non-test code. -- Every symbol a package exports from its public barrel carries a JSDoc block: what it does, and - where a caller could reasonably pick a different export, when to reach for it. Do not restate the - type. How much prose, and whether an `@example` is required at all, follows the tier table under - Doc blocks below. +- Every symbol a package exports from its public barrel carries a JSDoc block. It says what the + symbol does, and when to reach for it where a caller could reasonably pick a different export. Do + not restate the type. How much prose, and whether the block owes an `@example` at all, follows the + tier table under Doc blocks below. - **A module has one legal spelling, and it is never relative.** `./utils/toast` and `@/utils/toast` both resolved, so which one landed was down to who or what wrote the file. - Relative specifiers (`.`, `..`, `./*`, `../*`) are now a Biome error in `dapp/frontend`, + Relative specifiers (`.`, `..`, `./*`, `../*`) are now a Biome error in `example-dapps/*`, `canton-dappbooster`, and `canton-connect`, in all four positions: `import … from`, `export … from`, `export *`, and dynamic `import()`. - - The app reaches an intra-`src` module through `@/`, wired in `tsconfig.app.json` and + - The app reaches a module inside `src` through `@/`, wired in `tsconfig.app.json` and `vite.config.ts`. The one suppression in the repo is `vite.config.ts` itself, which defines that alias and so cannot use it. - - A library reaches an internal module through `#src/*`, the Node subpath imports declared in its - own `package.json`, and `@/` is an error there. Both libraries export `./src/index.ts` under the - `development` condition, so `dapp/frontend` compiles their source through its own Vite, where - `@` is the *app's* `src`; a library-internal `@/utils/cx` would resolve into the consumer's tree. - `#` is bound by spec to the nearest `package.json`, so no consumer alias can capture it. + - A library reaches an internal module through `#src/*`, the Node sub-path imports declared in + its own `package.json`, and `@/` is an error there. Both libraries export `./src/index.ts` under + the `development` condition, so `example-dapps/amulet-vesting` compiles their source through its + own Vite, where `@` is the *app's* `src`. A library-internal `@/utils/cx` would resolve into the + consumer's tree. The spec binds `#` to the nearest `package.json`, so no consumer alias can + capture it. - That map is `"#src/*": { "types": [four targets], "default": "./src/*" }` because no single - target satisfies every resolver: `tsc` needs the extension spelled out and walks the array until - one resolves, which is how one key covers `.ts`, `.tsx`, and folder entries; rolldown ignores - the array but resolves the extensionless `default` itself. Keep both conditions in step when - adding a key. - - Imports carry no file extension, and tsdown inlines every internal module, so no `#` or `@/` - specifier reaches `dist`. -- `kit/` and any `scripts/` folder are exempt from both rules: plain `.mjs` and Bash, run by `node` - and `bash` directly, with no bundler and no `imports` map to reach through. Relative specifiers - with extensions are correct there and lint allows them, kebab-case filenames included. + target satisfies every resolver. `tsc` needs the extension spelled out and walks the array until + one resolves, which is how one key covers `.ts`, `.tsx`, and folder entries. `rolldown` ignores + the array but resolves the `default` without an extension itself. Keep both conditions in step + when adding a key. + - Imports carry no file extension, and `tsdown` bundles every internal module into the output, so + no `#` or `@/` specifier reaches `dist`. +- Any `scripts/` folder is exempt from both rules. They hold plain `.mjs` and Bash, run + by `node` and `bash` directly, with no build step and no `imports` map to reach through. Relative + specifiers with extensions are correct there and lint allows them, kebab-case filenames included. + The one exception is `canton-create/scripts/record-demo.ts`, which runs on Bun for the reason in + `canton-create/CLAUDE.md`. ## Doc blocks -The tier decides how much prose a doc block carries and whether it owes an `@example`. Every tier is -derivable from the repo's own naming, which is what makes the floors and the ceilings checkable. +The tier decides how much prose a doc block carries and whether it owes an `@example`. The repo's +own naming determines every tier, which is what makes the floors and the ceilings checkable. | Tier | Detected by | Prose | Example | |------|-------------|-------|---------| | Component | exported function returning `ReactElement` | 2 to 6 lines: what it renders, which neighbour to pick instead, any a11y or state contract it owns | 1 to 2 | | Hook | `use` prefix | 2 to 4 lines: what it does, when to reach for it over the component that wraps it | 1 | -| Callable util | any other exported function | aim for one sentence, because the example is the spec | 1, input to output | -| Props type | `Props` suffix | one sentence, only where a prop carries a non-obvious contract | optional | +| Utility function | any other exported function | aim for one sentence, because the example is the spec | 1, input to output | +| Props type | `Props` suffix | one sentence, only where a prop carries a contract that is not obvious | optional | | Result type, status union | `Result` suffix, or a union of string literals | one sentence | none required | -| Config object | reached by the caller passing it in rather than being handed one back | 1 to 3 lines, defaults included | 1 | +| Config object | the caller passes it in rather than getting one back | 1 to 3 lines, defaults included | 1 | -`kit/docs-check.mjs` enforces the example requirement and a ceiling per tier that sits above the -table: 6 prose lines for a component or a callable util, 4 for a hook or a config object, 3 for a -props or result type, and 8 lines inside any one `@example`. The ceilings are deliberately loose — -they catch a block that has become an essay, not one that spent a second sentence well. Whether the -prose restates the type, inventories fields, or is merely long is the review call no check can make. +`scripts/docs-check.mjs` enforces the example requirement, plus a ceiling per tier set higher than +the table: -Which tags a block may carry is decided the same way, so that two authors write the same block: +- 6 prose lines for a component or a utility function +- 4 for a hook or a config object +- 3 for a props or result type +- 8 lines inside any one `@example` + +The ceilings are loose on purpose: they catch a block that has become an essay, not one that spent a second sentence well. +Whether the prose restates the type, inventories fields, or is merely long is the review call no +check can make. + +The same rules decide which tags a block may carry, so that two authors write the same block: | Tag | Owed by | Refused on | |-----|---------|------------| | `@category` | every barrel export, from that package's `categoryOrder` and nothing else | n/a | | `@example` | whatever the tier table asks for | a third one; a fourth is never the fix | -| `@throws` | any hook or callable util that throws, saying what triggers it | n/a | +| `@throws` | any hook or utility function that throws, saying what triggers it | n/a | | `@see` | a component whose folder holds an `anatomy.ts` | n/a | -| `@param` | never required: one sentence plus a compiled example is the spec | a callable that takes no parameters | -| `@returns` | never required | a hook, whose result type is the contract; any callable whose return type is itself an export | +| `@param` | never required: one sentence plus a compiled example is the spec | a function that takes no parameters | +| `@returns` | never required | a hook, whose result type is the contract; any function whose return type is itself an export | -- **`kit/typedoc.shared.json` holds every option that resolves *per package*, and both packages +- **`scripts/typedoc.shared.json` holds every option that resolves *per package*, and both packages extend it.** `blockTags`, `modifierTags`, `inlineTags` and `sourceLinkTemplate` all behave like - `categoryOrder` under `entryPointStrategy: "packages"`: set in `kit/typedoc.json` they are - accepted and silently ignored, which reads as the option not working. Adding a tag is a decision - about every block in the repo, so it is a review conversation and not a free choice. -- **`sourceLinkTemplate` looks redundant and is load-bearing.** It is character-for-character what - typedoc builds by itself, so deleting it changes no link. What it also does is skip the - `git remote get-url origin` call typedoc otherwise makes to learn the repo address for every + `categoryOrder` under `entryPointStrategy: "packages"`. In `scripts/typedoc.json`, TypeDoc accepts + them and ignores them without a word, which reads as the option not working. Adding a tag is a + decision about every block in the repo, so it is a review conversation and not a free choice. +- **`sourceLinkTemplate` looks redundant, and the build needs it.** It is character-for-character what + TypeDoc builds by itself, so deleting it changes no link. It also skips the + `git remote get-url origin` call TypeDoc otherwise makes to learn the repo address for every "Defined in" link. Vercel's checkout leaves no `origin`, so that call fails there, and the warning it logs fails the whole build under `treatWarningsAsErrors`. A local run cannot see any of this: the remote resolves, so the warning never fires and both templates emit the same URL. Force it with `typedoc --gitRemote nope`. -- **Typedoc owns that list, and `treatWarningsAsErrors` is what makes it bite.** An off-list tag is - a comment-parsing warning, which `treatValidationWarningsAsErrors` does not cover, so without it - an invented `@precondition` renders as prose and no gate objects. +- **TypeDoc owns that list, and `treatWarningsAsErrors` is what makes an off-list tag fail the build.** An off-list tag is + a comment-parsing warning, which `treatValidationWarningsAsErrors` does not cover. Without it, an + invented `@precondition` renders as prose and no gate objects. - **`@internal` is how a symbol stays out of the public reference.** Something deliberately not - public carries it, is still documented in source, and never reaches the site. Nothing is promoted - to the public barrel just so it can be documented. + public carries it, keeps its docs in source, and never reaches the site. Nothing goes into the + public barrel only so it can carry docs. - **A change that invalidates a doc block, a README, or an `architecture.md` seam updates it in the same commit.** A doc corrected one PR later was wrong in `main` for as long as that took. -- Description presence is split between the two gates: typedoc owns interfaces, type aliases, - classes, enums and plain variables, `docs-check.mjs` owns exported functions. Not a preference — - typedoc reaches a function only through its call signature, and asking it to check those also makes - it demand a caption on every function-typed interface member, which the rule against annotating - members one by one forbids. -- Every `@example` is compiled. Each package carries a root `doc-fixtures.d.ts` declaring the +- The two gates split description presence. TypeDoc owns interfaces, type aliases, classes, enums + and plain variables. `docs-check.mjs` owns exported functions. This is not a preference. + TypeDoc reaches a function only through its call signature. Asking it to check those also makes + it demand a caption on every function-typed interface member. The rule against annotating + members one by one forbids that. +- The check compiles every `@example`. Each package carries a root `doc-fixtures.d.ts` declaring the placeholder vocabulary examples may lean on, and an example may use only what that file declares. -- A throw is the callable's contract wherever inside it the `throw` sits, so the check descends into - nested closures and follows one hop through a `#src/*` import: every canton-connect hook throws +- A throw is the function's contract wherever inside it the `throw` sits. So the check descends into + nested closures and follows one hop through a `#src/*` import: every `canton-connect` hook throws through `useCantonConnectContext`, and `getExplorerLink` through a module-level guard. It stops at - one hop, and at a throw the function's own `catch` swallows — `useCopyToClipboard` returns that + one hop, and at a throw the function's own `catch` swallows. `useCopyToClipboard` returns that failure as a value and owes no `@throws`. ## The generated reference -Its shape was chosen by reading wagmi, Mantine and TanStack Query rather than invented, because a -reader arrives with habits from those. All three group the sidebar by *what a symbol is*, make every -leaf something you call or render, and keep parameter and return types inside that symbol's page. +Its shape comes from reading wagmi, Mantine and TanStack Query rather than from invention, because +a reader arrives with habits from those. All three group the sidebar by *what a symbol is*, and +make every leaf something you call or render. They keep parameter and return types inside that +symbol's page. -- **Every barrel export carries an `@category`, and the vocabulary is closed**: `Components`, +- **Every barrel export carries an `@category`, and the vocabulary is a closed list**: `Components`, `Hooks`, `Utilities`, plus `Configuration`, `Types` and `Errors` where a package needs them. A new name is a decision about the whole reference, so it is a review conversation and not a free - choice. `docs-check.mjs` errors on an untagged export; untagged, it would fall back into typedoc's - TypeScript-kind buckets, which is the flat "every interface together" listing the categories exist - to replace. + choice. `docs-check.mjs` errors on an untagged export. Untagged, it would fall back into + TypeDoc's TypeScript-kind buckets, the flat "every interface together" listing the categories + exist to replace. - **The tag is `@category`, never `@group`.** Both render the same headings, but `includeGroups` also wraps each package's modules in a `Modules` node, while categories nest under the module. - **A category holds a symbol and its supporting types**, so `Identifier` and `IdentifierProps` sit - together. That is what `sort: alphabetical-ignoring-documents` is for: typedoc's default sorts by + together. That is what `sort: alphabetical-ignoring-documents` is for. TypeDoc's default sorts by kind first, which lists every `*Props` ahead of every component it belongs to. -- **A sub-path that exists for packaging reasons is merged, not filed separately.** `src/connect.ts` - is a separate entry point because of what it pulls into a consumer's graph, which is no reason - to file a component away from the others, so it carries `@mergeModuleWith Main`. The tag - needs a bare `@module` beside it or the comment is not read as a module comment and the merge is - skipped in silence, with the `unusedMergeModuleWith` validation not catching it either. Where a - sub-path is merged, the component's own block states the import path, since the tree no longer - shows it. `canton-connect`'s `testing` module stays separate: those are doubles, and a fake +- **A sub-path that exists for packaging reasons merges into the main module.** `src/connect.ts` + is a separate entry point because of what it pulls into a consumer's graph. That is no reason to + file a component away from the others, so it carries `@mergeModuleWith Main`. The tag needs a + bare `@module` beside it. Otherwise TypeDoc does not read the comment as a module comment and + skips the merge in silence, and the `unusedMergeModuleWith` validation does not catch it either. + Where a sub-path merges, the component's own block states the import path, since the tree no + longer shows it. `canton-connect`'s `testing` module stays separate: those are doubles, and a fake provider listed beside the real one is a trap rather than a convenience. -- **A re-export cannot be tagged.** typedoc resolves it to the upstream declaration, so a local - `@category` on `export type { PrepareExecuteParams }` is ignored. `defaultCategory` catches those, - which is safe only because everything with a local declaration is required to be tagged. +- **A re-export cannot carry a tag.** TypeDoc resolves it to the upstream declaration, so it ignores + a local `@category` on `export type { PrepareExecuteParams }`. `defaultCategory` catches those, + which is safe only because every symbol with a local declaration must carry a tag. - **`categoryOrder`, `defaultCategory`, `categorizeByGroup`, `groupOrder` and `sort` resolve per package** under `entryPointStrategy: "packages"`, so they live in each package's own - `typedoc.json`. In `kit/typedoc.json` they are accepted and silently ignored, which reads as the - tag not working rather than the option being in the wrong file. `kit/typedoc.json` owns entry - points, validation, and `navigation`. -- **A link in a README or doc block is absolute, or it is not a link.** typedoc copies every + `typedoc.json`. In `scripts/typedoc.json`, TypeDoc accepts them and ignores them without a word. + That reads as the tag not working rather than the option being in the wrong file. + `scripts/typedoc.json` owns entry points, validation, and `navigation`. +- **A link in a README or doc block is absolute, or it is not a link.** TypeDoc copies every relative link target into the published site and points the link at the copy, and no option - disables it. So a relative path silently republishes the file, frozen at build time, and a `.ts` - target is served as `video/mp2t` and downloads instead of rendering. A file worth reaching from - the reference gets its full `https://github.com/BootNodeDev/canton-dappbooster/blob/main/…` URL; - a contributor-infrastructure file nobody reaches from the site is named in backticks and not - linked. Nothing enforces this, so it is a review check. + disables it. So a relative path silently republishes the file, frozen at build time. The site + also serves a `.ts` target as `video/mp2t`, which downloads instead of rendering. A file worth + reaching from the reference gets its full + `https://github.com/BootNodeDev/canton-dappbooster/blob/main/…` URL. Name a + contributor-infrastructure file nobody reaches from the site in backticks, without a link. + Nothing enforces this, so it is a review check. -## Authoring a Component or Hook +## Authoring a component or hook -Applies wherever a component or hook is written, app or library alike. Only *styling* differs by -package, because only `canton-dappbooster` splits markup from styles across a package boundary; its -`CLAUDE.md` owns that contract. Nothing below differs. +Applies wherever someone writes a component or hook, app or library alike. Only *styling* differs +by package, because only `canton-dappbooster` splits markup from styles across a package boundary; +its `CLAUDE.md` owns that contract. Nothing below differs. -- Render the element that carries the meaning, and keep the component legal where it is used: one +- Render the element that carries the meaning, and keep the component legal where it renders: one rendered inline is a `span`, not a `div`. - **Every state change a sighted user can see must reach assistive tech too.** Icons are `aria-hidden`, so a state carried only by an icon needs a live region or a changing accessible name. Two buttons where one looks selected need `aria-pressed`. Never leave this to the consumer. -- Expose that state on the element as `aria-*` or `data-*`, never through a class name alone, so the - styling hook and the accessibility state stay one source of truth. +- Expose that state on the element as `aria-*` or `data-*`, never through a class name alone. The + styling hook and the accessibility state then stay one source of truth. - `ref` is an ordinary prop (React 19), so do not reach for `forwardRef`. Do not declare it until a consumer needs one: a published prop is a contract owed forever, and adding it later is non-breaking. - **State a component reads from a provider is never also a prop.** `isConnecting` and `partyId` - on `` shadowed the wallet session, so a caller could contradict a connect already - in flight and the component had to pick a winner. One source, and a consumer wanting other + on `` shadowed the wallet session. A caller could then contradict a connect already + in flight, and the component had to pick a winner. One source, and a consumer wanting other behaviour composes the hook the provider already exports. This is what RainbowKit and ConnectKit do: no state props, a render-prop that *exposes* the same state if markup must differ. - **A consumer's handler composes with the component's own action, never replaces it.** Run theirs first and treat the built-in as the default action, so `preventDefault` opts out explicitly; `onClick ?? doTheThing` silently drops the behaviour the component exists for. Where a state machine owns the handler, merge through its own utility (`mergeProps` in Zag) rather than by - hand, so a handler the library adds later is not missed. -- **A ternary is for a two-way toggle between two things to render; a guard clause is for bailing + hand. A handler the library adds later then still runs. +- **A ternary is for a two-way toggle between two things to render. A guard clause is for bailing out of the whole render** (loading, error, no data). Two real faces get - `cond ? : `; a bail-out gets an `if` above the return. + `cond ? : `; a bail-out gets an `if` before the return. - Tests assert on roles, accessible names, and whatever contract the component declares. Never on styling. -## Working Rules +## Working rules - Use **pnpm** only (never npm or yarn). - This is a pnpm workspaces monorepo: one `pnpm install` from the repo root installs and links every package. There is no per-package install step. -- Run a subproject script either by `cd ` or by using `pnpm -C run + + diff --git a/canton-create/scaffold/starter/package.json b/canton-create/scaffold/starter/package.json new file mode 100644 index 00000000..4e2e288a --- /dev/null +++ b/canton-create/scaffold/starter/package.json @@ -0,0 +1,29 @@ +{ + "name": "@bootnodedev/canton-scaffold-starter", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host localhost --port 3012 --strictPort", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "@bootnodedev/canton-connect": "^0.5.6", + "@bootnodedev/canton-dappbooster": "^0.5.6", + "@bootnodedev/canton-theme": "^0.5.6", + "@canton-network/core-types": "^1.8.0", + "@canton-network/dapp-sdk": "1.5.1", + "@walletconnect/sign-client": "^2.23.9", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^6.0.0", + "typescript": "^5.9.3", + "vite": "^8.0.0" + } +} diff --git a/canton-create/scaffold/starter/src/App.tsx b/canton-create/scaffold/starter/src/App.tsx new file mode 100644 index 00000000..e92a141f --- /dev/null +++ b/canton-create/scaffold/starter/src/App.tsx @@ -0,0 +1,52 @@ +import { + type CantonConnectConfig, + CantonConnectProvider, + createMockAdapter, + useAccount, +} from '@bootnodedev/canton-connect' +import { Identifier, ThemeProvider } from '@bootnodedev/canton-dappbooster' +import { WalletButton } from '@bootnodedev/canton-dappbooster/connect' +import { RemoteAdapter } from '@canton-network/dapp-sdk' +import type { ReactElement } from 'react' + +// The mock wallet answers the connect flow and nothing else, so the app runs before a real +// wallet is reachable. A Wallet Gateway is listed when .env names one. See .env.example. +const mockWallet = import.meta.env.VITE_MOCK_WALLET === 'true' ? [createMockAdapter()] : [] +const gatewayUrl = import.meta.env.VITE_WALLET_GATEWAY_URL +const gateway = gatewayUrl + ? [new RemoteAdapter({ name: 'Wallet Gateway', rpcUrl: gatewayUrl })] + : [] + +const connectConfig: CantonConnectConfig = { + appName: 'Canton dApp', + additionalAdapters: [...mockWallet, ...gateway], +} + +const Session = (): ReactElement | null => { + const { account, status } = useAccount() + + // `idle` is "not determined yet": the provider is still restoring a previous session. + if (status === 'idle') { + return null + } + if (account === undefined) { + return

No wallet connected.

+ } + return ( +

+ Connected as +

+ ) +} + +export const App = (): ReactElement => ( + + +
+

Canton dApp

+ + +
+
+
+) diff --git a/canton-create/scaffold/starter/src/index.css b/canton-create/scaffold/starter/src/index.css new file mode 100644 index 00000000..c72c75ee --- /dev/null +++ b/canton-create/scaffold/starter/src/index.css @@ -0,0 +1,16 @@ +@import "@bootnodedev/canton-theme/default.css"; + +body { + background: var(--cnc-bg); + color: var(--cnc-text); + font-family: system-ui, sans-serif; + margin: 0; +} + +main { + display: grid; + gap: 1.5rem; + margin: 0 auto; + max-width: 40rem; + padding: 4rem 1rem; +} diff --git a/canton-create/scaffold/starter/src/main.tsx b/canton-create/scaffold/starter/src/main.tsx new file mode 100644 index 00000000..c3b9abe7 --- /dev/null +++ b/canton-create/scaffold/starter/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './index.css' + +const rootEl = document.getElementById('root') +if (rootEl === null) { + throw new Error('Root element #root not found') +} + +createRoot(rootEl).render( + + + , +) diff --git a/canton-create/scaffold/starter/src/vite-env.d.ts b/canton-create/scaffold/starter/src/vite-env.d.ts new file mode 100644 index 00000000..95bdc39f --- /dev/null +++ b/canton-create/scaffold/starter/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// + +interface ImportMetaEnv { + readonly VITE_MOCK_WALLET?: string + readonly VITE_WALLET_GATEWAY_URL?: string +} diff --git a/canton-create/scaffold/starter/tsconfig.app.json b/canton-create/scaffold/starter/tsconfig.app.json new file mode 100644 index 00000000..ad427244 --- /dev/null +++ b/canton-create/scaffold/starter/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "customConditions": ["development"], + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/dapp/frontend/tsconfig.json b/canton-create/scaffold/starter/tsconfig.json similarity index 100% rename from dapp/frontend/tsconfig.json rename to canton-create/scaffold/starter/tsconfig.json diff --git a/canton-create/scaffold/starter/tsconfig.node.json b/canton-create/scaffold/starter/tsconfig.node.json new file mode 100644 index 00000000..5d494bdd --- /dev/null +++ b/canton-create/scaffold/starter/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/canton-create/scaffold/starter/vite.config.ts b/canton-create/scaffold/starter/vite.config.ts new file mode 100644 index 00000000..ae745180 --- /dev/null +++ b/canton-create/scaffold/starter/vite.config.ts @@ -0,0 +1,6 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/canton-create/scripts/record-demo.ts b/canton-create/scripts/record-demo.ts new file mode 100755 index 00000000..e99d95ed --- /dev/null +++ b/canton-create/scripts/record-demo.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env bun +import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { stripVTControlCharacters } from 'node:util' + +const COLS = 92 +const ROWS = 26 +const IDLE_CAP_SECONDS = 2 +const TIME_LIMIT_SECONDS = 15 +const FRAME_MERGE_SECONDS = 0.15 +const PROJECT = 'my-dapp' +const ENTER = '\r' +const DOWN = '\u001B[B' + +const PKG = path.resolve(import.meta.dir, '..') +const OUT = path.join(PKG, 'demo.svg') + +const PNPM = Bun.which('pnpm') +if (PNPM === null) { + throw new Error('pnpm not found on PATH') +} + +type Chunk = [seconds: number, data: string] + +const work = mkdtempSync(path.join(tmpdir(), 'canton-create-demo-')) +process.on('exit', () => rmSync(work, { recursive: true, force: true })) +const stage = path.join(work, 'stage') +const shimDir = path.join(work, 'shim') +mkdirSync(stage) +mkdirSync(shimDir) + +// The tarball, not this folder: scaffold/ here holds node_modules and builds that `files` leaves out. +const pack = Bun.spawnSync([PNPM, 'pack', '--pack-destination', work], { + cwd: PKG, + stderr: 'inherit', +}) +const tarball = pack.stdout.toString().trim().split('\n').at(-1) +if (pack.exitCode !== 0 || tarball === undefined) { + throw new Error('pnpm pack failed') +} +Bun.spawnSync(['tar', '-xzf', tarball, '-C', work]) +const CLI = path.join(work, 'package', 'dist', 'index.js') + +// The recorded command line is the one a user types, while the code it runs is this working tree. +const shim = path.join(shimDir, 'pnpm') +writeFileSync( + shim, + `#!/bin/sh +if [ "$1" = "create" ] && [ "$2" = "canton-dappbooster" ]; then + shift 2 + export npm_config_user_agent=pnpm + exec node "${CLI}" "$@" +fi +exec "${PNPM}" "$@" +`, +) +chmodSync(shim, 0o755) + +const decoder = new TextDecoder() +const started = performance.now() +const events: Chunk[] = [] +let seen = '' + +const proc = Bun.spawn(['bash', '--norc', '--noprofile', '-i'], { + cwd: stage, + env: { + ...process.env, + PS1: '$ ', + PATH: `${shimDir}:${process.env.PATH}`, + TERM: 'xterm-256color', + }, + terminal: { + cols: COLS, + rows: ROWS, + data(_terminal, chunk) { + const text = decoder.decode(chunk, { stream: true }) + events.push([(performance.now() - started) / 1000, text]) + seen += text + }, + }, +}) +const terminal = proc.terminal +if (terminal === undefined) { + throw new Error('Bun did not open a terminal for the shell') +} + +const pause = (seconds: number): Promise => Bun.sleep(seconds * 1000) + +const expect = async (text: string, timeoutSeconds: number): Promise => { + const deadline = performance.now() + timeoutSeconds * 1000 + while (performance.now() < deadline) { + if (stripVTControlCharacters(seen).includes(text)) { + return + } + await pause(0.1) + } + throw new Error(`timed out waiting for "${text}"\n--- tail ---\n${seen.slice(-1500)}`) +} + +const typeOut = async (text: string): Promise => { + for (const char of text) { + terminal.write(char) + await pause(0.085) + } +} + +try { + await expect('$ ', 15) + await pause(0.7) + await typeOut('pnpm create canton-dappbooster') + await pause(0.5) + terminal.write(ENTER) + + await expect('Project name', 30) + await pause(1) + await typeOut(PROJECT) + await pause(0.6) + terminal.write(ENTER) + + await expect('Choose whether to install a Canton local network', 15) + await pause(1.3) + terminal.write(DOWN) + await pause(0.8) + terminal.write(ENTER) + + await expect('Choose the dApp your project starts from', 15) + await pause(1.3) + terminal.write(ENTER) + + // Past the time limit on purpose, so the trim lands on a whole frame. + await expect('Installing packages', 60) + await pause(6) +} finally { + terminal.close() + proc.kill() + await proc.exited +} + +let shift = 0 +let previous = 0 +const frames: Chunk[] = [] +for (const [stamp, data] of events) { + const gap = stamp - previous + if (gap > IDLE_CAP_SECONDS) { + shift += gap - IDLE_CAP_SECONDS + } + previous = stamp + const capped = Number((stamp - shift).toFixed(6)) + if (capped > TIME_LIMIT_SECONDS) { + break + } + const last = frames.at(-1) + // Merging only changes when bytes are flushed, never which bytes, so the screen stays exact. + if (last !== undefined && capped - last[0] < FRAME_MERGE_SECONDS) { + last[1] += data + } else { + frames.push([capped, data]) + } +} + +const cast = path.join(work, 'demo.cast') +const header = { version: 2, width: COLS, height: ROWS } +const lines = [header, ...frames.map(([seconds, data]) => [seconds, 'o', data])] +writeFileSync(cast, lines.map((line) => JSON.stringify(line)).join('\n')) + +const convert = Bun.spawnSync( + [ + PNPM, + 'dlx', + 'svg-term-cli', + '--in', + cast, + '--out', + OUT, + '--window', + '--width', + String(COLS), + '--height', + String(ROWS), + '--padding', + '10', + ], + { stdout: 'inherit', stderr: 'inherit' }, +) +if (convert.exitCode !== 0) { + throw new Error(`svg-term-cli exited with ${convert.exitCode}`) +} + +const lastFrame = frames.at(-1)?.[0] ?? 0 +console.log( + `wrote ${OUT}: ${frames.length} frames, ${lastFrame.toFixed(1)}s, ${Math.floor(statSync(OUT).size / 1024)} KB`, +) diff --git a/canton-create/scripts/try.sh b/canton-create/scripts/try.sh new file mode 100755 index 00000000..f90dc156 --- /dev/null +++ b/canton-create/scripts/try.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Runs the CLI and the example dApps from ./.try as if they were published. +# +# pnpm -C canton-create try # then follow what it prints +# pnpm -C canton-create try:clean # wind down whatever was started, remove .try +set -euo pipefail + +PKG="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT="$(dirname "$PKG")" +TRY="$PKG/.try" + +clean() { + if [ ! -d "$TRY" ]; then + echo "nothing to clean: $TRY does not exist" + return + fi + local project pid cwd + for project in "$TRY"/*/; do + [ -f "$project/scripts/dev-stack.sh" ] && [ -d "$project/node_modules" ] || continue + echo "==> winding down $(basename "$project")" + (cd "$project" && bash scripts/dev-stack.sh down) || true + if [ -f "$project/.canton-localnet/canton-barebones.config.json" ]; then + if docker info >/dev/null 2>&1; then + (cd "$project/.canton-localnet" && "$project/node_modules/.bin/canton-barebones" reset) || true + else + echo "[!] Docker is not running: the LocalNet volumes of $(basename "$project") were not removed" + fi + fi + done + local real + real="$(cd "$TRY" && pwd -P)" + for pid in $(lsof -nP -iTCP -sTCP:LISTEN -t 2>/dev/null | sort -u); do + cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' || true)" + case "$cwd" in "$real"/*) echo "==> stopping pid $pid ($cwd)"; kill "$pid" 2>/dev/null || true ;; esac + done + rm -rf "$TRY" + echo "removed $TRY" +} + +main() { +mkdir -p "$TRY" +find "$TRY" -maxdepth 1 -name '*.tgz' -delete +rm -rf "$TRY/node_modules" "$TRY/package-lock.json" +packed="$(cd "$PKG" && pnpm pack --pack-destination "$TRY" | tail -1)" + +# dlx caches an install by its spec string, so a content hash in the name makes every build a new spec. +hash="$(shasum -a 256 "$packed" | cut -c1-8)" +tgz="${packed%.tgz}-$hash.tgz" +mv "$packed" "$tgz" + +# npm, not pnpm: a pnpm install in a directory inside the monorepo lands in the root lockfile. +printf '{ "name": "try", "private": true }\n' >"$TRY/package.json" +(cd "$TRY" && npm install --no-audit --no-fund --loglevel=error "$tgz") + +# Extracted, not installed: the CLI only copies an example's files, and npm would pull its whole tree. +examples=() +for manifest in "$ROOT"/example-dapps/*/package.json; do + [ -f "$manifest" ] || continue + name="$(node -p "require('$manifest').name")" + example_tgz="$(cd "$(dirname "$manifest")" && pnpm pack --pack-destination "$TRY" | tail -1)" + mkdir -p "$TRY/node_modules/$name" + tar -xzf "$example_tgz" -C "$TRY/node_modules/$name" --strip-components=1 + rm "$example_tgz" + examples+=("${name#@bootnodedev/canton-example-}") +done + +# While the scaffold's library range is not on npm, the printed flow skips the install and adds the published libs. +range="$(tar -xzOf "$tgz" package/scaffold/starter/package.json | node -e "process.stdin.on('data',(d)=>console.log(JSON.parse(d).dependencies['@bootnodedev/canton-connect']))")" +published="$(npm view @bootnodedev/canton-connect version 2>/dev/null || true)" +libs="@bootnodedev/canton-connect@^$published @bootnodedev/canton-dappbooster@^$published @bootnodedev/canton-theme@^$published" + +# INIT_CWD is where `pnpm -C ... try` was typed; the script itself runs in the package dir. +rel="$(node -e "console.log(require('node:path').relative(process.env.INIT_CWD || process.cwd(), '$TRY') || '.')")" + +printf '\nPacked %s into %s and installed it there, with the examples: %s. Now, as a user would:\n\n cd %s\n' \ + "$(basename "$tgz")" "$rel" "${examples[*]:-none}" "$rel" +if [ -n "$(npm view "@bootnodedev/canton-connect@$range" version 2>/dev/null)" ]; then + cat < answer those questions, and --help lists every flag (after a -- with npm). +TXT +printf '\nDone trying? pnpm -C canton-create try:clean (stops the stack, resets the LocalNet, removes %s)\n' "$rel" +} + +case "${1:-}" in + "") main ;; + clean) clean ;; + *) echo "usage: try.sh [clean]" >&2; exit 2 ;; +esac diff --git a/canton-create/src/CancelledError.ts b/canton-create/src/CancelledError.ts new file mode 100644 index 00000000..6a677a27 --- /dev/null +++ b/canton-create/src/CancelledError.ts @@ -0,0 +1,7 @@ +/** Ctrl-C ended the run. The CLI exits with 130, as a shell does after SIGINT. */ +export class CancelledError extends Error { + constructor() { + super('Cancelled.') + this.name = 'CancelledError' + } +} diff --git a/canton-create/src/apps.ts b/canton-create/src/apps.ts new file mode 100644 index 00000000..ed68ca50 --- /dev/null +++ b/canton-create/src/apps.ts @@ -0,0 +1,21 @@ +import type { RadioOption } from '#src/components/RadioList' + +export const STARTER = 'starter' + +/** The examples the dApp list offers, by the name `--example` takes. */ +export const EXAMPLES: Record> = { + 'amulet-vesting': { + label: 'Amulet Vesting', + hint: 'A vesting dApp frontend and its contracts.', + }, +} + +/** Every dApp the list offers: the starter first, then the examples. */ +export const APPS: RadioOption[] = [ + { + value: STARTER, + label: 'Starter dApp', + hint: 'Barebones, connects to a wallet and gives you a sample contract.', + }, + ...Object.entries(EXAMPLES).map(([value, example]) => ({ value, ...example })), +] diff --git a/canton-create/src/cliArgs.test.ts b/canton-create/src/cliArgs.test.ts new file mode 100644 index 00000000..05f98afd --- /dev/null +++ b/canton-create/src/cliArgs.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { parseCliArgs } from '#src/cliArgs' + +describe('parseCliArgs', () => { + it('reads a flag after the -- pnpm forwards', () => { + const { values, directory } = parseCliArgs(['my-app', '--', '--localnet']) + + expect(directory).toBe('my-app') + expect(values.localnet).toBe(true) + }) + + it('refuses a second directory', () => { + expect(() => parseCliArgs(['my-app', 'other'])).toThrow( + 'Expected one directory, got: my-app other', + ) + }) +}) diff --git a/canton-create/src/cliArgs.ts b/canton-create/src/cliArgs.ts new file mode 100644 index 00000000..f02ae781 --- /dev/null +++ b/canton-create/src/cliArgs.ts @@ -0,0 +1,20 @@ +import { parseArgs } from 'node:util' + +/** The flags and the directory. pnpm forwards a `--`, so it is dropped and a flag after it counts. */ +export const parseCliArgs = (args: string[]) => { + const { values, positionals } = parseArgs({ + args: args.filter((arg) => arg !== '--'), + allowPositionals: true, + options: { + 'disable-git': { type: 'boolean' }, + example: { type: 'string' }, + help: { type: 'boolean', short: 'h' }, + localnet: { type: 'boolean' }, + 'skip-install': { type: 'boolean' }, + }, + }) + if (positionals.length > 1) { + throw new Error(`Expected one directory, got: ${positionals.join(' ')}`) + } + return { values, directory: positionals[0] } +} diff --git a/canton-create/src/components/App.test.tsx b/canton-create/src/components/App.test.tsx new file mode 100644 index 00000000..8d78367c --- /dev/null +++ b/canton-create/src/components/App.test.tsx @@ -0,0 +1,277 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { render } from 'ink-testing-library' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { App } from '#src/components/App' +import { type PackedScaffold, packScaffold } from '#src/testing/packedScaffold' + +const ENTER = '\r' +const DOWN = '\u001B[B' +const CTRL_C = '\u0003' + +let packed: PackedScaffold +let work: string + +beforeAll(() => { + packed = packScaffold() +}) + +afterAll(() => { + packed.remove() +}) + +beforeEach(() => { + work = fs.mkdtempSync(path.join(os.tmpdir(), 'create-canton-dappbooster-app-')) +}) + +afterEach(() => { + fs.rmSync(work, { recursive: true, force: true }) +}) + +const waitFor = async (frame: () => string | undefined, text: string): Promise => { + const deadline = Date.now() + 10_000 + while (!(frame() ?? '').includes(text)) { + if (Date.now() > deadline) { + throw new Error(`"${text}" never appeared in:\n${frame()}`) + } + await new Promise((resolve) => setTimeout(resolve, 20)) + } +} + +// A component reads keys only once its effect has run, so a key sent on first paint can be lost. +const pressUntil = async ( + stdin: { write: (data: string) => void }, + key: string, + frame: () => string | undefined, + text: string, +): Promise => { + const deadline = Date.now() + 10_000 + while (!(frame() ?? '').includes(text)) { + if (Date.now() > deadline) { + throw new Error(`"${text}" never appeared in:\n${frame()}`) + } + stdin.write(key) + await new Promise((resolve) => setTimeout(resolve, 100)) + } +} + +const fixture = path.resolve(import.meta.dirname, '../testing/fixtures/example-hello') + +// An npm that never finishes, so a test can cancel the install while it runs. +const withHangingNpm = async (test: (pidFile: string) => Promise): Promise => { + const bin = path.join(work, 'bin') + const pidFile = path.join(work, 'npm.pid') + fs.mkdirSync(bin) + fs.writeFileSync(path.join(bin, 'npm'), `#!/bin/sh\necho $ > "${pidFile}"\nexec sleep 30\n`, { + mode: 0o755, + }) + const pathBefore = process.env.PATH + process.env.PATH = `${bin}${path.delimiter}${pathBefore}` + try { + await test(pidFile) + } finally { + process.env.PATH = pathBefore + } +} + +const waitUntilGone = async (target: string): Promise => { + const deadline = Date.now() + 10_000 + while (fs.existsSync(target)) { + if (Date.now() > deadline) { + throw new Error(`${target} was never removed`) + } + await new Promise((resolve) => setTimeout(resolve, 20)) + } +} + +const isRunning = (pid: number): boolean => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +const waitForFile = async (file: string): Promise => { + const deadline = Date.now() + 10_000 + while (!fs.existsSync(file) || fs.readFileSync(file, 'utf8').trim() === '') { + if (Date.now() > deadline) { + throw new Error(`${file} never appeared`) + } + await new Promise((resolve) => setTimeout(resolve, 20)) + } +} + +interface Flags { + directory?: string + app?: string + localnet?: boolean + scaffoldDir?: string + install?: boolean +} + +const run = (flags: Flags) => + render( + , + ) + +describe('App', () => { + it('scaffolds what the flags name and ends with the next steps', async () => { + const target = path.join(work, 'my-app') + const { frames } = run({ directory: target, app: 'starter', localnet: false }) + const screen = () => frames.join('\n') + + await waitFor(screen, 'Post-install instructions') + + expect(screen()).toContain('✔ Copying starter') + expect(screen()).toContain('Install the packages with npm install') + expect(screen()).toContain('Mock Wallet') + expect(fs.existsSync(path.join(target, '.env'))).toBe(true) + expect(fs.existsSync(path.join(target, 'scripts/build-dar.mjs'))).toBe(true) + }) + + it('asks for the name, the local network and the dApp when the flags leave them out', async () => { + const target = path.join(work, 'typed') + const { frames, stdin } = run({}) + const screen = () => frames.join('\n') + + await waitFor(screen, 'Project name') + stdin.write(target) + await waitFor(screen, 'typed') + stdin.write(ENTER) + await waitFor(screen, 'Barebones') + await pressUntil(stdin, ENTER, screen, 'LocalNet: No localnet') + await waitFor(screen, 'Starter dApp') + await pressUntil(stdin, ENTER, screen, 'dApp: Starter dApp') + await waitFor(screen, 'Post-install instructions') + expect(fs.existsSync(path.join(target, 'scripts/dev-stack.sh'))).toBe(false) + }) + + it('adds the local network when Barebones is picked', async () => { + const target = path.join(work, 'net') + const { frames, stdin } = run({ directory: target, app: 'starter' }) + const screen = () => frames.join('\n') + + await waitFor(screen, 'Barebones') + await pressUntil(stdin, DOWN, screen, '❯ Barebones') + await pressUntil(stdin, ENTER, screen, 'LocalNet: Barebones') + await waitFor(screen, 'Post-install instructions') + + expect(screen()).toContain('with Docker running choose "Stack up"') + expect(fs.existsSync(path.join(target, 'scripts/dev-stack.sh'))).toBe(true) + }) + + it('starts from an example instead of the starter', async () => { + const target = path.join(work, 'from-example') + const { frames } = run({ directory: target, app: fixture, localnet: false }) + const screen = () => frames.join('\n') + + await waitFor(screen, 'Post-install instructions') + + expect(screen()).toContain('Fetching') + expect(screen()).toContain('Point .env at your network') + expect(screen()).not.toContain('Mock Wallet') + expect(fs.existsSync(path.join(target, 'src/index.ts'))).toBe(true) + }) + + it('refuses a folder that is not empty', async () => { + const taken = path.join(work, 'taken') + fs.mkdirSync(taken) + fs.writeFileSync(path.join(taken, 'file'), '') + const { lastFrame, stdin } = run({}) + + await waitFor(lastFrame, 'Project name') + stdin.write(taken) + await waitFor(lastFrame, 'taken') + stdin.write(ENTER) + + await waitFor(lastFrame, 'exists and is not empty.') + }) + + it('exits at Ctrl-C during the questions without writing anything', async () => { + const target = path.join(work, 'never') + const { lastFrame, stdin } = run({}) + + await waitFor(lastFrame, 'Project name') + stdin.write(target) + await waitFor(lastFrame, 'never') + stdin.write(ENTER) + await pressUntil(stdin, ENTER, lastFrame, 'LocalNet: No localnet') + await waitFor(lastFrame, 'Choose the dApp your project starts from') + stdin.write(CTRL_C) + await new Promise((resolve) => setTimeout(resolve, 200)) + stdin.write(ENTER) + await new Promise((resolve) => setTimeout(resolve, 500)) + + expect(fs.existsSync(target)).toBe(false) + }) + + it('stops the install at Ctrl-C and removes the folder it started', async () => { + await withHangingNpm(async (pidFile) => { + const target = path.join(work, 'cancelled') + const { frames, stdin } = run({ + directory: target, + app: 'starter', + localnet: false, + install: true, + }) + + await waitForFile(pidFile) + stdin.write(CTRL_C) + await waitFor(() => frames.join('\n'), 'Cancelled.') + await waitUntilGone(target) + + expect(isRunning(Number(fs.readFileSync(pidFile, 'utf8')))).toBe(false) + }) + }) + + it('stops the install at SIGINT and removes the folder it started', async () => { + await withHangingNpm(async (pidFile) => { + const target = path.join(work, 'interrupted') + const { frames } = run({ directory: target, app: 'starter', localnet: false, install: true }) + + await waitForFile(pidFile) + process.emit('SIGINT') + await waitFor(() => frames.join('\n'), 'Cancelled.') + await waitUntilGone(target) + + expect(isRunning(Number(fs.readFileSync(pidFile, 'utf8')))).toBe(false) + }) + }) + + it('shows the failure and removes the folder it started', async () => { + const target = path.join(work, 'broken') + const { frames } = run({ + directory: target, + app: 'starter', + localnet: false, + scaffoldDir: path.join(work, 'missing'), + }) + + await waitFor(() => frames.join('\n'), '✗ Copying starter') + + expect(fs.existsSync(target)).toBe(false) + }) + + it('removes every folder it created on the way to a nested target', async () => { + const { frames } = run({ + directory: path.join(work, 'a/b/c'), + app: fixture, + localnet: true, + scaffoldDir: path.join(work, 'missing'), + }) + + await waitFor(() => frames.join('\n'), '✗') + await waitUntilGone(path.join(work, 'a')) + }) +}) diff --git a/canton-create/src/components/App.tsx b/canton-create/src/components/App.tsx new file mode 100644 index 00000000..48d87660 --- /dev/null +++ b/canton-create/src/components/App.tsx @@ -0,0 +1,251 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { defaultTheme, extendTheme, ThemeProvider } from '@inkjs/ui' +import { Box, useApp, useInput, useStdin } from 'ink' +import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react' +import { STARTER } from '#src/apps' +import { CancelledError } from '#src/CancelledError' +import { AppSelect } from '#src/components/AppSelect' +import { SCREEN_WIDTH } from '#src/components/Divider' +import { LocalnetSelect } from '#src/components/LocalnetSelect' +import { PostInstall } from '#src/components/PostInstall' +import { ProjectName } from '#src/components/ProjectName' +import { type Progress, Section } from '#src/components/Section' +import { Title } from '#src/components/Title' +import { createEnvFile } from '#src/env' +import { fetchExample, resolveExampleSpec } from '#src/example' +import { gitSkipReason, initGit } from '#src/git' +import { install as installPackages, type PackageManager } from '#src/packageManager' +import { firstMissingFolder, readProject, removeScaffold } from '#src/projectDirectory' +import { scaffold } from '#src/scaffold' + +const theme = extendTheme(defaultTheme, { + components: { + Spinner: { styles: { frame: () => ({ color: 'green' }) } }, + }, +}) + +export interface AppProps { + directory?: string + app?: string + localnet?: boolean + scaffoldDir: string + manager: PackageManager + install: boolean + git: boolean +} + +interface Plan { + signal: AbortSignal + directory: string + app: string + localnet: boolean + scaffoldDir: string + manager: PackageManager + install: boolean + git: boolean +} + +interface SectionPlan { + title: string + run: (progress: Progress) => Promise +} + +const sectionsFor = (plan: Plan): SectionPlan[] => { + const targetDir = path.resolve(plan.directory) + const projectName = path.basename(targetDir) + const { app, manager } = plan + const sections: SectionPlan[] = [ + { + title: 'Scaffold', + run: async (progress) => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'canton-example-')) + try { + let appDir = path.join(plan.scaffoldDir, STARTER) + if (app !== STARTER) { + const spec = resolveExampleSpec(app) + progress.step(`Fetching ${spec}`) + appDir = await fetchExample(spec, workDir, undefined, plan.signal) + } + progress.step(`Copying ${app} into ${plan.directory}`) + scaffold({ + projectName, + targetDir, + appDir, + scaffoldDir: plan.scaffoldDir, + localnet: plan.localnet, + }) + } finally { + fs.rmSync(workDir, { recursive: true, force: true }) + } + if (createEnvFile(targetDir)) { + progress.step('Created .env from .env.example') + } + }, + }, + ] + + if (plan.install) { + sections.push({ + title: 'Installation', + run: async (progress) => { + progress.step(`Installing packages with ${manager}`) + await installPackages(manager, targetDir, plan.signal) + }, + }) + } + + if (plan.git) { + sections.push({ + title: 'Git', + run: async (progress) => { + const skipReason = await gitSkipReason(targetDir) + if (skipReason !== undefined) { + progress.warn(skipReason) + return + } + progress.step('Creating a git repository with a first commit') + if (!(await initGit(targetDir))) { + progress.warn('git init or the first commit failed, so the project has no repository.') + } + }, + }) + } + + // A cancelled step shows why it stopped, whatever error the abort raised inside it. + return sections.map((section) => ({ + ...section, + run: (progress) => + section.run(progress).catch((error: unknown) => { + throw plan.signal.aborted ? plan.signal.reason : error + }), + })) +} + +/** The whole run: title, the questions the flags left open, each section in turn, then next steps. */ +export const App = ({ + directory: givenDirectory, + app: givenApp, + localnet: givenLocalnet, + scaffoldDir, + manager, + install, + git, +}: AppProps): ReactElement => { + const { exit } = useApp() + const { isRawModeSupported } = useStdin() + const controller = useMemo(() => new AbortController(), []) + const [directory, setDirectory] = useState(givenDirectory) + const [app, setApp] = useState(givenApp) + const [localnet, setLocalnet] = useState(givenLocalnet) + const [finished, setFinished] = useState(0) + + const ready = directory !== undefined && app !== undefined && localnet !== undefined + const created = useMemo( + () => (directory !== undefined ? firstMissingFolder(path.resolve(directory)) : undefined), + [directory], + ) + const sections = useMemo( + () => + ready + ? sectionsFor({ + signal: controller.signal, + directory, + app, + localnet, + scaffoldDir, + manager, + install, + git, + }) + : [], + [controller, ready, directory, app, localnet, scaffoldDir, manager, install, git], + ) + const done = ready && finished === sections.length + const project = useMemo( + () => (done && directory !== undefined ? readProject(path.resolve(directory)) : undefined), + [done, directory], + ) + + const fail = useCallback( + (error: unknown) => { + if (directory !== undefined) { + removeScaffold(path.resolve(directory), created) + } + exit(error instanceof Error ? error : new Error(String(error))) + }, + [directory, created, exit], + ) + // A step the abort cannot stop still finishes, and no step starts after it. + const next = useCallback(() => { + if (controller.signal.aborted) { + fail(controller.signal.reason) + return + } + setFinished((count) => count + 1) + }, [controller, fail]) + + const cancel = useCallback(() => { + if (!ready || done) { + exit(new CancelledError()) + return + } + controller.abort(new CancelledError()) + }, [controller, ready, done, exit]) + // In raw mode Ctrl-C arrives as a key; without a terminal it arrives as SIGINT. + useInput( + (input, key) => { + if (key.ctrl && input === 'c') { + cancel() + } + }, + { isActive: isRawModeSupported }, + ) + useEffect(() => { + process.on('SIGINT', cancel) + return () => { + process.off('SIGINT', cancel) + } + }, [cancel]) + + useEffect(() => { + if (done) { + exit() + } + }, [done, exit]) + + return ( + + + + {givenDirectory === undefined && <ProjectName onSubmit={setDirectory} />} + {directory !== undefined && givenLocalnet === undefined && ( + <LocalnetSelect onChoose={setLocalnet} /> + )} + {directory !== undefined && localnet !== undefined && givenApp === undefined && ( + <AppSelect onSelect={setApp} /> + )} + {sections.slice(0, finished + 1).map((section) => ( + <Section + key={section.title} + title={section.title} + run={section.run} + onDone={next} + onError={fail} + /> + ))} + {project !== undefined && directory !== undefined && localnet !== undefined && ( + <PostInstall + directory={directory} + starter={app === STARTER} + localnet={localnet} + manager={manager} + installed={install} + project={project} + /> + )} + </Box> + </ThemeProvider> + ) +} diff --git a/canton-create/src/components/AppSelect.tsx b/canton-create/src/components/AppSelect.tsx new file mode 100644 index 00000000..31b52375 --- /dev/null +++ b/canton-create/src/components/AppSelect.tsx @@ -0,0 +1,14 @@ +import type { ReactElement } from 'react' +import { APPS } from '#src/apps' +import { RadioList } from '#src/components/RadioList' + +/** The dApp to start from, the starter or an example, asked when `--example` is not given. */ +export const AppSelect = ({ onSelect }: { onSelect: (app: string) => void }): ReactElement => ( + <RadioList + section="dApp Installation" + prompt="Choose the dApp your project starts from." + name="dApp" + options={APPS} + onSelect={onSelect} + /> +) diff --git a/canton-create/src/components/Divider.tsx b/canton-create/src/components/Divider.tsx new file mode 100644 index 00000000..da254f4f --- /dev/null +++ b/canton-create/src/components/Divider.tsx @@ -0,0 +1,22 @@ +import { Text } from 'ink' +import type { ReactElement } from 'react' + +export const SCREEN_WIDTH = 80 + +const TITLE_PADDING = ' ' + +export const Divider = ({ title }: { title: string }): ReactElement => { + const lineLength = SCREEN_WIDTH - title.length - TITLE_PADDING.length * 2 + const left = Math.floor(lineLength / 2) + return ( + <Text> + <Text color="gray">{'─'.repeat(left)}</Text> + {TITLE_PADDING} + <Text bold color="whiteBright"> + {title} + </Text> + {TITLE_PADDING} + <Text color="gray">{'─'.repeat(lineLength - left)}</Text> + </Text> + ) +} diff --git a/canton-create/src/components/LocalnetSelect.tsx b/canton-create/src/components/LocalnetSelect.tsx new file mode 100644 index 00000000..be29c571 --- /dev/null +++ b/canton-create/src/components/LocalnetSelect.tsx @@ -0,0 +1,28 @@ +import type { ReactElement } from 'react' +import { RadioList, type RadioOption } from '#src/components/RadioList' + +const BAREBONES = 'barebones' + +const OPTIONS: RadioOption[] = [ + { value: 'none', label: 'No localnet', hint: "You'll provide your own." }, + { + value: BAREBONES, + label: 'Barebones', + hint: 'Minimal local Canton stack for developer workflows.', + }, +] + +/** Whether to add the local network, asked first when `--localnet` is not given. */ +export const LocalnetSelect = ({ + onChoose, +}: { + onChoose: (localnet: boolean) => void +}): ReactElement => ( + <RadioList + section="LocalNet Installation" + prompt="Choose whether to install a Canton local network." + name="LocalNet" + options={OPTIONS} + onSelect={(value) => onChoose(value === BAREBONES)} + /> +) diff --git a/canton-create/src/components/PostInstall.tsx b/canton-create/src/components/PostInstall.tsx new file mode 100644 index 00000000..cebd9c2d --- /dev/null +++ b/canton-create/src/components/PostInstall.tsx @@ -0,0 +1,133 @@ +import { Box, Text } from 'ink' +import Link from 'ink-link' +import type { ReactElement, ReactNode } from 'react' +import { Divider } from '#src/components/Divider' +import { type PackageManager, runScript } from '#src/packageManager' +import type { ProjectFacts } from '#src/projectDirectory' + +const DOCS_URL = 'https://docs.dappbooster.cc/' +const COMPONENTS_URL = 'https://components.dappbooster.cc/' +const ISSUES_URL = 'https://github.com/BootNodeDev/canton-dappbooster/issues' + +export interface PostInstallProps { + directory: string + starter: boolean + localnet: boolean + manager: PackageManager + installed: boolean + project: ProjectFacts +} + +const Command = ({ children }: { children: string }): ReactElement => ( + <Text color="gray">{children}</Text> +) + +const DockerStep = (): ReactElement => ( + <Text> + - Docker must be running (or start it from the <Command>pnpm stack</Command> menu below). + </Text> +) + +const StackStep = (): ReactElement => ( + <Text> + - Run <Command>pnpm stack</Command>, with Docker running choose "Stack up". + </Text> +) + +const ownNetworkSteps = ( + project: ProjectFacts, + manager: PackageManager, + starter: boolean, +): ReactNode[] => { + const run = (script: string): string => runScript(manager, script) + const dev = ( + <Text key="dev"> + - Start the app with <Command>{run('dev')}</Command> and open http://localhost:3012 + </Text> + ) + const { dar } = project + if (starter) { + return [ + dev, + <Text key="mock">- Pick "Mock Wallet" to connect with nothing installed.</Text>, + ...(dar === undefined + ? [] + : [ + <Text key="contract"> + - The sample contract in daml/ is optional. To put it on a ledger, point + CANTON_JSON_API_URL and CANTON_BACKEND_TOKEN in .env at your participant, then run{' '} + <Command>{run('build-dar')}</Command> and{' '} + <Command>{`${run('deploy-dar')} -- ${dar}`}</Command> + </Text>, + ]), + ] + } + return [ + <Text key="network"> + - Point .env at your network: CANTON_JSON_API_URL, CANTON_BACKEND_TOKEN and the VITE_ URLs + {project.needsSpliceTag ? ', plus SPLICE_TAG for the contract build' : ''}. + </Text>, + ...(dar === undefined + ? [] + : [ + <Text key="deploy"> + - Build and deploy the contract: <Command>{run('build-dar')}</Command>, then{' '} + <Command>{`${run('deploy-dar')} -- ${dar}`}</Command> + </Text>, + ]), + ...(project.hasBootstrap + ? [ + <Text key="bootstrap"> + - Run <Command>{run('bootstrap')}</Command> once, to create what the app needs on the + ledger. + </Text>, + ] + : []), + dev, + ] +} + +/** The closing screen: how to start the project, then where to read more. */ +export const PostInstall = ({ + directory, + starter, + localnet, + manager, + installed, + project, +}: PostInstallProps): ReactElement => ( + <Box flexDirection="column" rowGap={1} paddingBottom={1}> + <Divider title="Post-install instructions" /> + <Text color="whiteBright">To start development on your project:</Text> + <Box flexDirection="column"> + {localnet && <DockerStep />} + <Text> + - Move into the project's folder with <Command>{`cd ${directory}`}</Command> + </Text> + {!installed && ( + <Text> + - Install the packages with <Command>{`${manager} install`}</Command> + </Text> + )} + {localnet ? <StackStep /> : ownNetworkSteps(project, manager, starter)} + </Box> + {localnet && ( + <Text bold color="yellow"> + Warning: the first run pulls about 10 GB and can take a few minutes to start. + </Text> + )} + <Text color="whiteBright">More info:</Text> + <Box flexDirection="column"> + <Text> + - The <Link url={DOCS_URL}>documentation</Link> has more detailed instructions about running + the stack. + </Text> + <Text> + - Components documentation is available <Link url={COMPONENTS_URL}>here</Link>. + </Text> + <Text> + - Report issues with dAppBooster in the repo's <Link url={ISSUES_URL}>issue tracker</Link>. + </Text> + </Box> + </Box> +) diff --git a/canton-create/src/components/ProjectName.tsx b/canton-create/src/components/ProjectName.tsx new file mode 100644 index 00000000..a443fdc4 --- /dev/null +++ b/canton-create/src/components/ProjectName.tsx @@ -0,0 +1,58 @@ +import path from 'node:path' +import { TextInput } from '@inkjs/ui' +import { Box, Text } from 'ink' +import { type ReactElement, useState } from 'react' +import { isEmptyDir } from '#src/projectDirectory' + +const problemWith = (name: string): string | undefined => { + if (name === '') { + return 'A project name is required.' + } + if (!isEmptyDir(path.resolve(name))) { + return `${name} exists and is not empty.` + } + return undefined +} + +/** Asks for the folder when the command line names none. */ +export const ProjectName = ({ + onSubmit, +}: { + onSubmit: (directory: string) => void +}): ReactElement => { + const [answer, setAnswer] = useState<string>() + const [error, setError] = useState<string>() + + const submit = (value: string): void => { + const name = value.trim() + const problem = problemWith(name) + setError(problem) + if (problem === undefined) { + setAnswer(name) + onSubmit(name) + } + } + + return ( + <Box flexDirection="column" rowGap={1}> + <Box flexDirection="column"> + <Box> + <Text color="whiteBright">Project name: </Text> + {answer === undefined ? ( + <TextInput placeholder="my-canton-dapp" onSubmit={submit} /> + ) : ( + <Text bold color="green"> + {answer} + </Text> + )} + </Box> + <Text color="gray">The folder to create the project in, new or empty.</Text> + </Box> + {error !== undefined && ( + <Text bold color="red"> + {error} + </Text> + )} + </Box> + ) +} diff --git a/canton-create/src/components/RadioList.tsx b/canton-create/src/components/RadioList.tsx new file mode 100644 index 00000000..4cc72c9e --- /dev/null +++ b/canton-create/src/components/RadioList.tsx @@ -0,0 +1,80 @@ +import { Box, Text, useInput } from 'ink' +import { type ReactElement, useState } from 'react' +import { Divider } from '#src/components/Divider' + +export interface RadioOption { + value: string + label: string + hint: string +} + +interface RadioListProps { + section: string + prompt: string + name: string + options: RadioOption[] + onSelect: (value: string) => void +} + +/** A single choice under a divider: arrow keys move, Enter picks, and the answer replaces the list. */ +export const RadioList = ({ + section, + prompt, + name, + options, + onSelect, +}: RadioListProps): ReactElement => { + const [focused, setFocused] = useState(0) + const [picked, setPicked] = useState<RadioOption>() + + useInput( + (_input, key) => { + if (key.upArrow) { + setFocused((index) => (index - 1 + options.length) % options.length) + } + if (key.downArrow) { + setFocused((index) => (index + 1) % options.length) + } + if (key.return) { + const option = options[focused] as RadioOption + setPicked(option) + onSelect(option.value) + } + }, + { isActive: picked === undefined }, + ) + + return ( + <Box flexDirection="column" rowGap={1}> + <Divider title={section} /> + {picked === undefined ? ( + <Box flexDirection="column" rowGap={1}> + <Text color="white">{prompt}</Text> + <Box flexDirection="column"> + {options.map((option, index) => ( + <Box key={option.value} flexDirection="column"> + <Text bold={index === focused} color={index === focused ? 'green' : 'white'}> + {index === focused ? '❯ ' : ' '} + {option.label} + </Text> + <Text color="gray"> + {' '} + {option.hint} + </Text> + </Box> + ))} + </Box> + </Box> + ) : ( + <Text> + <Text bold color="white"> + {name}: + </Text>{' '} + <Text bold color="green"> + {picked.label} + </Text> + </Text> + )} + </Box> + ) +} diff --git a/canton-create/src/components/Section.tsx b/canton-create/src/components/Section.tsx new file mode 100644 index 00000000..d787370d --- /dev/null +++ b/canton-create/src/components/Section.tsx @@ -0,0 +1,90 @@ +import { Spinner } from '@inkjs/ui' +import { Box, Text } from 'ink' +import { type ReactElement, useEffect, useState } from 'react' +import { Divider } from '#src/components/Divider' + +export interface Progress { + step: (label: string) => void + warn: (message: string) => void +} + +export interface SectionProps { + title: string + run: (progress: Progress) => Promise<void> + onDone: () => void + onError: (error: unknown) => void +} + +type Line = { kind: 'step' | 'warning'; text: string } +type Status = 'running' | 'done' | 'failed' + +const messageOf = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +const StepLine = ({ text, status }: { text: string; status: Status }): ReactElement => { + if (status === 'running') { + return <Spinner label={text} /> + } + return status === 'done' ? ( + <Text> + <Text color="green">✔</Text> {text} + </Text> + ) : ( + <Text> + <Text color="red">✗</Text> {text} <Text color="red">Error</Text> + </Text> + ) +} + +/** One phase of the run: a divider, then a line per step, the last one live until `run` settles. */ +export const Section = ({ title, run, onDone, onError }: SectionProps): ReactElement => { + const [lines, setLines] = useState<Line[]>([]) + const [status, setStatus] = useState<Status>('running') + const [failure, setFailure] = useState<{ reason: unknown }>() + + useEffect(() => { + const add = (kind: Line['kind']) => (text: string) => + setLines((previous) => [...previous, { kind, text }]) + run({ step: add('step'), warn: add('warning') }).then( + () => setStatus('done'), + (reason: unknown) => { + setStatus('failed') + setFailure({ reason }) + }, + ) + }, [run]) + + // Reported from an effect so the error is on screen before the app exits. + useEffect(() => { + if (status === 'done') { + onDone() + } + if (failure !== undefined) { + onError(failure.reason) + } + }, [status, failure, onDone, onError]) + + const lastStep = lines.findLastIndex((line) => line.kind === 'step') + + return ( + <Box flexDirection="column" rowGap={1}> + <Divider title={title} /> + <Box flexDirection="column"> + {lines.map((line, index) => + line.kind === 'warning' ? ( + <Text key={`warning:${line.text}`}> + <Text color="yellow">⚠</Text> {line.text} + </Text> + ) : ( + <StepLine + key={`step:${line.text}`} + text={line.text} + status={index === lastStep ? status : 'done'} + /> + ), + )} + {failure !== undefined && <Text color="red">{messageOf(failure.reason)}</Text>} + </Box> + </Box> + ) +} diff --git a/canton-create/src/components/Title.tsx b/canton-create/src/components/Title.tsx new file mode 100644 index 00000000..73bc26b6 --- /dev/null +++ b/canton-create/src/components/Title.tsx @@ -0,0 +1,23 @@ +import { Box, Text } from 'ink' +import Gradient from 'ink-gradient' +import type { ReactElement } from 'react' + +// "dAppBooster" in cfonts' chrome font, drawn once so the bundle ships no font files. +const BANNER = [ + ' ╔╦╗ ╔═╗ ╔═╗ ╔═╗ ╔╗ ╔═╗ ╔═╗ ╔═╗ ╔╦╗ ╔═╗ ╦═╗', + ' ║║ ╠═╣ ╠═╝ ╠═╝ ╠╩╗ ║ ║ ║ ║ ╚═╗ ║ ║╣ ╠╦╝', + ' ═╩╝ ╩ ╩ ╩ ╩ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╩ ╚═╝ ╩╚═', +].join('\n') + +export const Title = (): ReactElement => ( + <Box alignItems="flex-end"> + <Gradient colors={['#ff438c', '#bb1d79', '#8b46a4', '#6a2581']}> + <Text>{BANNER}</Text> + </Gradient> + <Box marginLeft={2}> + <Text backgroundColor="#bb1d79" bold color="whiteBright"> + {' Canton '} + </Text> + </Box> + </Box> +) diff --git a/canton-create/src/env.test.ts b/canton-create/src/env.test.ts new file mode 100644 index 00000000..f77a018c --- /dev/null +++ b/canton-create/src/env.test.ts @@ -0,0 +1,37 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createEnvFile } from '#src/env' + +let work: string + +beforeEach(() => { + work = fs.mkdtempSync(path.join(os.tmpdir(), 'create-canton-dappbooster-env-')) +}) + +afterEach(() => { + fs.rmSync(work, { recursive: true, force: true }) +}) + +describe('createEnvFile', () => { + it('copies .env.example to .env', () => { + fs.writeFileSync(path.join(work, '.env.example'), 'VITE_MOCK_WALLET=true\n') + + expect(createEnvFile(work)).toBe(true) + expect(fs.readFileSync(path.join(work, '.env'), 'utf8')).toBe('VITE_MOCK_WALLET=true\n') + }) + + it('does nothing without a .env.example', () => { + expect(createEnvFile(work)).toBe(false) + expect(fs.existsSync(path.join(work, '.env'))).toBe(false) + }) + + it('never overwrites an existing .env', () => { + fs.writeFileSync(path.join(work, '.env.example'), 'FROM_EXAMPLE=1\n') + fs.writeFileSync(path.join(work, '.env'), 'KEPT=1\n') + + expect(createEnvFile(work)).toBe(false) + expect(fs.readFileSync(path.join(work, '.env'), 'utf8')).toBe('KEPT=1\n') + }) +}) diff --git a/canton-create/src/env.ts b/canton-create/src/env.ts new file mode 100644 index 00000000..f868328e --- /dev/null +++ b/canton-create/src/env.ts @@ -0,0 +1,13 @@ +import fs from 'node:fs' +import path from 'node:path' + +/** Copies `.env.example` to `.env`. False when there is no example or a `.env` already. */ +export const createEnvFile = (projectDir: string): boolean => { + const example = path.join(projectDir, '.env.example') + const env = path.join(projectDir, '.env') + if (!fs.existsSync(example) || fs.existsSync(env)) { + return false + } + fs.copyFileSync(example, env) + return true +} diff --git a/canton-create/src/example.test.ts b/canton-create/src/example.test.ts new file mode 100644 index 00000000..425ed793 --- /dev/null +++ b/canton-create/src/example.test.ts @@ -0,0 +1,152 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchExample, resolveExampleSpec } from '#src/example' +import { scaffold } from '#src/scaffold' +import { type PackedScaffold, packScaffold } from '#src/testing/packedScaffold' + +const fixture = path.resolve(import.meta.dirname, 'testing/fixtures/example-hello') +const { version } = JSON.parse( + fs.readFileSync(path.resolve(import.meta.dirname, '../package.json'), 'utf8'), +) + +const installExample = (dir: string, installedVersion: string): string => { + const installed = path.join(dir, 'node_modules/@bootnodedev/canton-example-hello') + fs.mkdirSync(installed, { recursive: true }) + fs.writeFileSync( + path.join(installed, 'package.json'), + JSON.stringify({ name: 'installed', version: installedVersion }), + ) + return installed +} + +let packed: PackedScaffold +let work: string + +beforeAll(() => { + packed = packScaffold() +}) + +afterAll(() => { + packed.remove() +}) + +beforeEach(() => { + work = fs.mkdtempSync(path.join(os.tmpdir(), 'create-canton-dappbooster-example-')) +}) + +afterEach(() => { + vi.unstubAllEnvs() + fs.rmSync(work, { recursive: true, force: true }) +}) + +describe('resolveExampleSpec', () => { + it('maps a bare name onto the published example scope', () => { + expect(resolveExampleSpec('amulet-vesting')).toBe('@bootnodedev/canton-example-amulet-vesting') + }) + + it.each(['amulet-vesting@0.5.6', 'amulet-vesting@^0.5.0', 'amulet-vesting@latest', 'foo.js@1'])( + 'maps %s onto the scope too, version and all', + (input) => { + expect(resolveExampleSpec(input)).toBe(`@bootnodedev/canton-example-${input}`) + }, + ) + + it.each([ + '@acme/thing@1.2.3', + './local/dir', + 'https://x.test/pkg.tgz', + 'pkg.tgz', + 'github:acme/thing', + 'acme/thing', + 'git@github.com:acme/thing.git', + ])('passes %s through untouched', (input) => { + expect(resolveExampleSpec(input)).toBe(input) + }) +}) + +describe('fetchExample', () => { + it('packs the example and hands back the package, which is the app', async () => { + const appDir = await fetchExample(fixture, work) + + expect(fs.existsSync(path.join(appDir, 'package.json'))).toBe(true) + expect(fs.existsSync(path.join(appDir, 'src/index.ts'))).toBe(true) + }) + + it('takes a tarball whose top folder is not package/', async () => { + const archiveDir = path.join(work, 'archive') + fs.cpSync(fixture, path.join(archiveDir, 'repo-sha'), { recursive: true }) + const tarball = path.join(archiveDir, 'repo-sha.tar.gz') + execFileSync('tar', ['-czf', tarball, '-C', archiveDir, 'repo-sha']) + + const appDir = await fetchExample(tarball, work) + + expect(fs.existsSync(path.join(appDir, 'package.json'))).toBe(true) + expect(fs.existsSync(path.join(appDir, 'src/index.ts'))).toBe(true) + }) + + it('uses a copy on its own version already installed where it runs, without npm', async () => { + const installed = installExample(work, version) + + const appDir = await fetchExample('@bootnodedev/canton-example-hello', work, work) + + expect(fs.realpathSync(appDir)).toBe(fs.realpathSync(installed)) + }) + + it('asks npm when the installed copy is on another version', async () => { + installExample(work, '0.0.1') + const bin = path.join(work, 'bin') + const npmArgs = path.join(work, 'npm-args') + fs.mkdirSync(bin) + fs.writeFileSync(path.join(bin, 'npm'), `#!/bin/sh\necho "$@" > "${npmArgs}"\nexit 1\n`, { + mode: 0o755, + }) + vi.stubEnv('PATH', `${bin}${path.delimiter}${process.env.PATH}`) + + await expect(fetchExample('@bootnodedev/canton-example-hello', work, work)).rejects.toThrow() + + expect(fs.readFileSync(npmArgs, 'utf8')).toContain('pack @bootnodedev/canton-example-hello') + }) +}) + +describe('scaffolding an example', () => { + it('names the project, drops the package-only fields and renames _gitignore', async () => { + const appDir = await fetchExample(fixture, work) + const target = path.join(work, 'hello') + + scaffold({ + projectName: 'hello', + targetDir: target, + appDir, + scaffoldDir: packed.scaffoldDir, + localnet: false, + }) + + const manifest = JSON.parse(fs.readFileSync(path.join(target, 'package.json'), 'utf8')) + expect(manifest).toEqual({ + name: 'hello', + private: true, + version: '0.0.0', + scripts: { dev: 'vite' }, + }) + expect(fs.existsSync(path.join(target, '.gitignore'))).toBe(true) + }) + + it('adds the local network on top of an example', async () => { + const appDir = await fetchExample(fixture, work) + const target = path.join(work, 'hello') + + scaffold({ + projectName: 'hello', + targetDir: target, + appDir, + scaffoldDir: packed.scaffoldDir, + localnet: true, + }) + + expect(fs.existsSync(path.join(target, 'scripts/dev-stack.sh'))).toBe(true) + expect(fs.existsSync(path.join(target, 'scripts/build-dar.mjs'))).toBe(false) + }) +}) diff --git a/canton-create/src/example.ts b/canton-create/src/example.ts new file mode 100644 index 00000000..bcbda9b8 --- /dev/null +++ b/canton-create/src/example.ts @@ -0,0 +1,68 @@ +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { promisify } from 'node:util' + +const run = promisify(execFile) + +const EXAMPLE_SCOPE = '@bootnodedev/canton-example-' +const PACKAGE_NAME = /^(@[a-z0-9-]+\/)?[a-z0-9][a-z0-9.-]*$/ +const UNSCOPED_NAME = /^[a-z0-9][a-z0-9._-]*(@[^:/@]+)?$/ +const TARBALL_FILE = /\.(tgz|tar\.gz|tar)$/ +const INSTALLER_VERSION: string = JSON.parse( + fs.readFileSync(path.resolve(import.meta.dirname, '..', 'package.json'), 'utf8'), +).version + +/** + * An unscoped name, with or without a version, means a published example, never another package + * of that name; anything else goes to npm as typed. + */ +export const resolveExampleSpec = (input: string): string => + UNSCOPED_NAME.test(input) && !TARBALL_FILE.test(input) ? `${EXAMPLE_SCOPE}${input}` : input + +// Only for a package name: a local folder spec would resolve to a working tree, node_modules included. +const installedExample = (spec: string, cwd: string): string | undefined => { + if (!PACKAGE_NAME.test(spec)) { + return undefined + } + try { + const manifest = createRequire(path.join(cwd, 'package.json')).resolve(`${spec}/package.json`) + const { version } = JSON.parse(fs.readFileSync(manifest, 'utf8')) + return version === INSTALLER_VERSION ? path.dirname(manifest) : undefined + } catch { + return undefined + } +} + +/** + * The folder holding an example's app: a copy on the installer's own version already installed + * where the CLI runs, as `try` arranges, else the package `npm pack` fetches into `workDir`. + * `npm pack` takes any spec npm understands: a name, a tarball URL or a local directory. + */ +export const fetchExample = async ( + spec: string, + workDir: string, + cwd = process.cwd(), + signal?: AbortSignal, +): Promise<string> => { + const installed = installedExample(spec, cwd) + if (installed !== undefined) { + return installed + } + const { stdout } = await run( + 'npm', + ['pack', spec, '--pack-destination', workDir, '--json', '--ignore-scripts'], + { signal }, + ) + const [packed] = JSON.parse(stdout) as { filename: string }[] + if (packed === undefined) { + throw new Error(`npm pack produced nothing for ${spec}`) + } + const appDir = path.join(workDir, 'app') + fs.mkdirSync(appDir) + // A tarball npm did not build keeps its own top folder instead of `package/`. + const archive = path.join(workDir, packed.filename) + await run('tar', ['-xzf', archive, '-C', appDir, '--strip-components=1'], { signal }) + return appDir +} diff --git a/canton-create/src/git.test.ts b/canton-create/src/git.test.ts new file mode 100644 index 00000000..4069b1cb --- /dev/null +++ b/canton-create/src/git.test.ts @@ -0,0 +1,54 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { gitSkipReason, initGit } from '#src/git' + +let work: string +let project: string + +const git = (...args: string[]): string => + execFileSync('git', args, { cwd: project, encoding: 'utf8' }).trim() + +beforeEach(() => { + work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'create-canton-dappbooster-git-'))) + project = path.join(work, 'project') + fs.mkdirSync(project) + const config = path.join(work, 'gitconfig') + fs.writeFileSync(config, '[user]\n\tname = Test\n\temail = test@example.com\n') + vi.stubEnv('GIT_CONFIG_GLOBAL', config) + vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') +}) + +afterEach(() => { + vi.unstubAllEnvs() + fs.rmSync(work, { recursive: true, force: true }) +}) + +describe('gitSkipReason', () => { + it('lets a folder outside any repository through', async () => { + expect(await gitSkipReason(project)).toBeUndefined() + }) + + it('skips a folder inside another repository', async () => { + execFileSync('git', ['init'], { cwd: work }) + + expect(await gitSkipReason(project)).toBe('Skipped: the folder is already inside a repository.') + }) +}) + +describe('initGit', () => { + it('creates a repository on main with one commit', async () => { + fs.writeFileSync(path.join(project, 'README.md'), '# project\n') + + expect(await initGit(project)).toBe(true) + expect(git('rev-parse', '--abbrev-ref', 'HEAD')).toBe('main') + expect(git('log', '--format=%s')).toBe('Initial commit from create-canton-dappbooster') + }) + + it('removes .git when the first commit fails', async () => { + expect(await initGit(project)).toBe(false) + expect(fs.existsSync(path.join(project, '.git'))).toBe(false) + }) +}) diff --git a/canton-create/src/git.ts b/canton-create/src/git.ts new file mode 100644 index 00000000..367d0894 --- /dev/null +++ b/canton-create/src/git.ts @@ -0,0 +1,46 @@ +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { promisify } from 'node:util' + +const run = promisify(execFile) + +const succeeds = async (command: string, args: string[], cwd: string): Promise<boolean> => { + try { + await run(command, args, { cwd }) + return true + } catch { + return false + } +} + +/** Why the project gets no repository of its own, or undefined when it should get one. */ +export const gitSkipReason = async (projectDir: string): Promise<string | undefined> => { + if (!(await succeeds('git', ['--version'], projectDir))) { + return 'Skipped: git is not installed.' + } + if ( + (await succeeds('git', ['rev-parse', '--is-inside-work-tree'], projectDir)) || + (await succeeds('hg', ['--cwd', '.', 'root'], projectDir)) + ) { + return 'Skipped: the folder is already inside a repository.' + } + return undefined +} + +/** `git init` and a first commit, as create-next-app does. Removes `.git` again if any step fails. */ +export const initGit = async (projectDir: string): Promise<boolean> => { + const git = (...args: string[]) => run('git', args, { cwd: projectDir }) + try { + await git('init') + if (!(await succeeds('git', ['config', 'init.defaultBranch'], projectDir))) { + await git('checkout', '-b', 'main') + } + await git('add', '-A') + await git('commit', '-m', 'Initial commit from create-canton-dappbooster') + return true + } catch { + fs.rmSync(path.join(projectDir, '.git'), { recursive: true, force: true }) + return false + } +} diff --git a/canton-create/src/index.tsx b/canton-create/src/index.tsx new file mode 100644 index 00000000..bce05258 --- /dev/null +++ b/canton-create/src/index.tsx @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import path from 'node:path' +import { render } from 'ink' +import { EXAMPLES, STARTER } from '#src/apps' +import { CancelledError } from '#src/CancelledError' +import { parseCliArgs } from '#src/cliArgs' +import { App } from '#src/components/App' +import { detectPackageManager } from '#src/packageManager' +import { isEmptyDir } from '#src/projectDirectory' + +const SCAFFOLD_DIR = path.resolve(import.meta.dirname, '..', 'scaffold') + +const USAGE = `Usage: create-canton-dappbooster [directory] [options] + +Options: + --example <name> start from an example dApp instead of the starter: ${Object.keys(EXAMPLES).join(', ')} + --localnet add a Canton network that runs on this computer, in Docker + --skip-install write the files and stop + --disable-git skip creating a git repository + -h, --help this text + +In a terminal, whatever the options leave out is asked. Without one, the directory is +required and the rest take their defaults: the starter, no local network. +` + +const fail = (message: string): never => { + process.stderr.write(`${message}\n`) + process.exit(1) +} + +const main = async (): Promise<void> => { + const { values, directory } = parseCliArgs(process.argv.slice(2)) + if (values.help === true) { + process.stdout.write(USAGE) + return + } + + const interactive = process.stdin.isTTY === true + if (directory === undefined && !interactive) { + fail(USAGE) + } + if (directory !== undefined && !isEmptyDir(path.resolve(directory))) { + fail(`${directory} exists and is not empty.`) + } + + const { waitUntilExit } = render( + <App + directory={directory} + app={values.example ?? (interactive ? undefined : STARTER)} + localnet={values.localnet ?? (interactive ? undefined : false)} + scaffoldDir={SCAFFOLD_DIR} + manager={detectPackageManager()} + install={values['skip-install'] !== true} + git={values['disable-git'] !== true} + />, + { exitOnCtrlC: false }, + ) + // The screen already shows what failed. + await waitUntilExit().catch((error: unknown) => { + process.exitCode = error instanceof CancelledError ? 130 : 1 + }) +} + +main().catch((error: unknown) => { + fail(error instanceof Error ? error.message : String(error)) +}) diff --git a/canton-create/src/merge.test.ts b/canton-create/src/merge.test.ts new file mode 100644 index 00000000..e8911aa8 --- /dev/null +++ b/canton-create/src/merge.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { mergeEnvExample, ScaffoldConflictError } from '#src/merge' + +describe('mergeEnvExample', () => { + it('appends a block that sets a new key', () => { + expect(mergeEnvExample('A=1\n', '# B\nB=2\n')).toBe('A=1\n\n# B\nB=2\n') + }) + + it('leaves out a block whose every key is already set', () => { + expect(mergeEnvExample('A=1\nB=2\n', '# A and B\nA=3\nB=4\n\nC=5\n')).toBe('A=1\nB=2\n\nC=5\n') + }) + + it('refuses a block that sets a key already set beside a new one', () => { + expect(() => mergeEnvExample('A=1\n', 'A=2\nB=3\n')).toThrow(ScaffoldConflictError) + }) + + it('does not count a commented-out key as set', () => { + expect(mergeEnvExample('# A=1\n', 'A=2\n')).toBe('# A=1\n\nA=2\n') + }) +}) diff --git a/canton-create/src/merge.ts b/canton-create/src/merge.ts new file mode 100644 index 00000000..e42774d2 --- /dev/null +++ b/canton-create/src/merge.ts @@ -0,0 +1,96 @@ +// A layer may add files and merge these fields; it may never rewrite what an earlier layer wrote. +// A conflict is a design error in scaffold/, so it throws rather than picking a winner. + +export const APPENDABLE = new Set(['.gitignore', '.env.example', 'README.md']) + +const ENV_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=/gm + +const assignedKeys = (text: string): string[] => + [...text.matchAll(ENV_ASSIGNMENT)].map(([, key]) => key as string) + +/** + * Appends a layer's `.env.example` blocks, leaving out a block whose every key is already set. A + * block setting only some of them would write those keys twice, so it throws. + */ +export const mergeEnvExample = (current: string, layer: string): string => { + const assigned = new Set(assignedKeys(current)) + const added = layer + .split(/\n{2,}/) + .map((block) => block.trim()) + .filter((block) => block !== '') + .filter((block) => { + const keys = assignedKeys(block) + const alreadySet = keys.filter((key) => assigned.has(key)) + if (alreadySet.length > 0 && alreadySet.length < keys.length) { + throw new ScaffoldConflictError( + `fragment redefines .env.example keys beside new ones: ${alreadySet.join(', ')}`, + ) + } + return alreadySet.length === 0 + }) + return added.length === 0 ? current : `${current.trimEnd()}\n\n${added.join('\n\n')}\n` +} + +const MERGED_FIELDS = ['scripts', 'dependencies', 'devDependencies'] as const +// A layer's own name, privacy, version and module type describe the layer, not the project. +const LAYER_FIELDS = new Set<string>([...MERGED_FIELDS, 'name', 'private', 'version', 'type']) + +type Manifest = Record<string, unknown> +type Field = Record<string, string> + +export class ScaffoldConflictError extends Error { + constructor(message: string) { + super(message) + this.name = 'ScaffoldConflictError' + } +} + +const sorted = (field: Field): Field => + Object.fromEntries(Object.entries(field).sort(([a], [b]) => a.localeCompare(b))) + +const mergeField = (name: string, base: Field, fragment: Field): Field => { + const clash = Object.keys(fragment).filter((key) => key in base) + if (clash.length > 0) { + throw new ScaffoldConflictError(`fragment redefines ${name}: ${clash.join(', ')}`) + } + return { ...base, ...fragment } +} + +export const mergeManifests = (base: Manifest, fragment: Manifest): Manifest => { + const dropped = Object.keys(fragment).filter((key) => !LAYER_FIELDS.has(key)) + if (dropped.length > 0) { + throw new ScaffoldConflictError( + `fragment sets fields the merge would drop: ${dropped.join(', ')}`, + ) + } + const merged: Manifest = { ...base } + for (const field of MERGED_FIELDS) { + const fromBase = (base[field] ?? {}) as Field + const fromFragment = (fragment[field] ?? {}) as Field + if (Object.keys(fromFragment).length === 0) { + continue + } + const combined = mergeField(field, fromBase, fromFragment) + merged[field] = field === 'scripts' ? combined : sorted(combined) + } + return merged +} + +// An example ships as an npm package; these describe that package, not the project made from it. +const PACKAGE_ONLY = new Set([ + 'name', + 'private', + 'version', + 'description', + 'license', + 'repository', + 'publishConfig', + 'files', +]) + +export const finalizeManifest = (manifest: Manifest, projectName: string): Manifest => { + const rest = Object.fromEntries( + Object.entries(manifest).filter(([key]) => !PACKAGE_ONLY.has(key)), + ) + return { name: projectName, private: true, version: '0.0.0', ...rest } +} diff --git a/canton-create/src/packageManager.ts b/canton-create/src/packageManager.ts new file mode 100644 index 00000000..ab6ce922 --- /dev/null +++ b/canton-create/src/packageManager.ts @@ -0,0 +1,56 @@ +import { spawn } from 'node:child_process' + +export type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn' + +const MANAGERS = new Set<PackageManager>(['bun', 'npm', 'pnpm', 'yarn']) + +const isPackageManager = (value: string): value is PackageManager => + MANAGERS.has(value as PackageManager) + +// `npm create x` runs through the invoking manager, which names itself in this variable; that is +// how create-vite and create-next-app decide too. +export const detectPackageManager = ( + userAgent: string | undefined = process.env.npm_config_user_agent, +): PackageManager => { + const name = userAgent?.split(' ')[0]?.split('/')[0] + return name !== undefined && isPackageManager(name) ? name : 'npm' +} + +export const runScript = (manager: PackageManager, script: string): string => + manager === 'npm' ? `npm run ${script}` : `${manager} ${script}` + +const OUTPUT_LINES_ON_FAILURE = 20 + +// Output is captured, not inherited: it would draw over the Ink screen. +export const install = ( + manager: PackageManager, + cwd: string, + signal?: AbortSignal, +): Promise<void> => + new Promise((resolve, reject) => { + const child = spawn(manager, ['install'], { cwd, stdio: ['ignore', 'pipe', 'pipe'], signal }) + let output = '' + const collect = (chunk: Buffer): void => { + output += chunk.toString() + } + child.stdout.on('data', collect) + child.stderr.on('data', collect) + // An abort settles on close, once the child has exited and the folder it wrote into can go. + child.on('error', (error) => { + if (!signal?.aborted) { + reject(error) + } + }) + child.on('close', (code) => { + if (signal?.aborted) { + reject(signal.reason) + return + } + if (code === 0) { + resolve() + return + } + const tail = output.trimEnd().split('\n').slice(-OUTPUT_LINES_ON_FAILURE).join('\n') + reject(new Error(`${manager} install exited with ${code}\n${tail}`)) + }) + }) diff --git a/canton-create/src/projectDirectory.ts b/canton-create/src/projectDirectory.ts new file mode 100644 index 00000000..2b5f9292 --- /dev/null +++ b/canton-create/src/projectDirectory.ts @@ -0,0 +1,56 @@ +import fs from 'node:fs' +import path from 'node:path' + +export const isEmptyDir = (dir: string): boolean => + !fs.existsSync(dir) || fs.readdirSync(dir).filter((name) => name !== '.git').length === 0 + +export interface ProjectFacts { + dar: string | undefined + needsSpliceTag: boolean + hasBootstrap: boolean +} + +/** What the closing screen needs to know about the project a run made. */ +export const readProject = (dir: string): ProjectFacts => { + const damlYaml = path.join(dir, 'daml', 'daml.yaml') + const yaml = fs.existsSync(damlYaml) ? fs.readFileSync(damlYaml, 'utf8') : '' + const field = (key: string) => yaml.match(new RegExp(`^${key}:\\s*(\\S+)`, 'm'))?.[1] + const name = field('name') + const version = field('version') + const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as { + scripts?: Record<string, string> + } + return { + dar: name && version ? `daml/.daml/dist/${name}-${version}.dar` : undefined, + needsSpliceTag: /^\s*-\s*deps\//m.test(yaml), + hasBootstrap: manifest.scripts?.bootstrap !== undefined, + } +} + +/** The highest folder on the way to `dir` that does not exist yet, or `undefined` when `dir` does. */ +export const firstMissingFolder = (dir: string): string | undefined => { + if (fs.existsSync(dir)) { + return undefined + } + let missing = dir + while (!fs.existsSync(path.dirname(missing))) { + missing = path.dirname(missing) + } + return missing +} + +/** + * Undoes a failed run: deletes the folders it created from `created` down, or empties the one it + * was given except `.git`. + */ +export const removeScaffold = (dir: string, created: string | undefined): void => { + if (created !== undefined) { + fs.rmSync(created, { recursive: true, force: true }) + return + } + for (const name of fs.readdirSync(dir)) { + if (name !== '.git') { + fs.rmSync(path.join(dir, name), { recursive: true, force: true }) + } + } +} diff --git a/canton-create/src/scaffold.test.ts b/canton-create/src/scaffold.test.ts new file mode 100644 index 00000000..1c8fac69 --- /dev/null +++ b/canton-create/src/scaffold.test.ts @@ -0,0 +1,233 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { ScaffoldConflictError } from '#src/merge' +import { scaffold } from '#src/scaffold' +import { type PackedScaffold, packScaffold } from '#src/testing/packedScaffold' + +let packed: PackedScaffold +let scaffoldDir: string +const starter = (): string => path.join(scaffoldDir, 'starter') + +beforeAll(() => { + packed = packScaffold() + scaffoldDir = packed.scaffoldDir +}) + +afterAll(() => { + packed.remove() +}) + +const read = (dir: string, file: string): string => fs.readFileSync(path.join(dir, file), 'utf8') +const manifest = (dir: string): Record<string, Record<string, string>> => + JSON.parse(read(dir, 'package.json')) as Record<string, Record<string, string>> + +let work: string + +beforeEach(() => { + work = fs.mkdtempSync(path.join(os.tmpdir(), 'create-canton-dappbooster-')) +}) + +afterEach(() => { + fs.rmSync(work, { recursive: true, force: true }) +}) + +describe('the starter', () => { + it('copies the starter, names the package and renames _gitignore', () => { + const target = path.join(work, 'my-app') + + scaffold({ + projectName: 'my-app', + targetDir: target, + appDir: starter(), + scaffoldDir, + localnet: false, + }) + + expect(fs.existsSync(path.join(target, 'src/App.tsx'))).toBe(true) + expect(fs.existsSync(path.join(target, '.gitignore'))).toBe(true) + expect(fs.existsSync(path.join(target, '_gitignore'))).toBe(false) + expect(fs.existsSync(path.join(target, 'pnpm-workspace.yaml'))).toBe(true) + expect(manifest(target).name).toBe('my-app') + expect(manifest(target).private).toBe(true) + expect(fs.existsSync(path.join(target, 'scripts/dev-stack.sh'))).toBe(false) + expect(fs.existsSync(path.join(target, 'node_modules'))).toBe(false) + }) + + it('adds the DAML tooling because the starter has a contract', () => { + const target = path.join(work, 'my-app') + + scaffold({ + projectName: 'my-app', + targetDir: target, + appDir: starter(), + scaffoldDir, + localnet: false, + }) + + expect(fs.existsSync(path.join(target, 'daml/daml.yaml'))).toBe(true) + expect(fs.existsSync(path.join(target, 'scripts/build-dar.mjs'))).toBe(true) + expect(manifest(target).scripts['build-dar']).toBeDefined() + expect(manifest(target).scripts['deploy-dar']).toBeDefined() + expect(read(target, '.gitignore')).toContain('daml/.daml/') + expect(read(target, 'README.md')).toContain('## Contract') + }) + + it('ships real ranges for the kit, never the workspace protocol', () => { + const target = path.join(work, 'my-app') + + scaffold({ + projectName: 'my-app', + targetDir: target, + appDir: starter(), + scaffoldDir, + localnet: false, + }) + + const ranges = Object.values(manifest(target).dependencies) + expect(ranges.some((range) => range.startsWith('workspace:'))).toBe(false) + expect(manifest(target).dependencies['@bootnodedev/canton-connect']).toMatch(/^\^\d+\.\d+\.\d+/) + }) +}) + +describe('the starter with the local network', () => { + it('adds the stack beside the app and merges the manifest', () => { + const target = path.join(work, 'my-app') + + scaffold({ + projectName: 'my-app', + targetDir: target, + appDir: starter(), + scaffoldDir, + localnet: true, + }) + + expect(fs.existsSync(path.join(target, 'scripts/dev-stack.sh'))).toBe(true) + expect(fs.existsSync(path.join(target, 'scripts/build-dar.mjs'))).toBe(true) + expect(fs.existsSync(path.join(target, 'daml/daml.yaml'))).toBe(true) + expect(fs.existsSync(path.join(target, 'wallet-gateway.config.json'))).toBe(true) + const { scripts, devDependencies } = manifest(target) + expect(scripts.dev).toBeDefined() + expect(scripts.stack).toBe('bash scripts/dev-stack.sh') + expect(scripts['wallet-gateway']).toBeDefined() + expect(devDependencies['@bootnodedev/canton-barebones']).toBeDefined() + expect(devDependencies['@canton-network/wallet-gateway-remote']).toBeDefined() + expect(devDependencies.vite).toBeDefined() + }) + + it('appends to the files every layer writes', () => { + const target = path.join(work, 'my-app') + + scaffold({ + projectName: 'my-app', + targetDir: target, + appDir: starter(), + scaffoldDir, + localnet: true, + }) + + expect(read(target, '.gitignore')).toContain('node_modules/') + expect(read(target, '.gitignore')).toContain('.canton-localnet/') + expect(read(target, '.gitignore')).toContain('.wallet-gateway/') + expect(read(target, '.env.example')).toContain('VITE_MOCK_WALLET') + expect(read(target, '.env.example')).toContain('CANTON_JSON_API_URL') + expect(read(target, '.env.example')).toContain('CANTON_AUTH_SECRET') + expect(read(target, '.env.example')).toContain('VITE_WALLET_GATEWAY_URL') + expect(read(target, 'README.md')).toContain('## Contract') + expect(read(target, 'README.md')).toContain('## Local network') + }) + + it('sets every .env.example key once', () => { + const target = path.join(work, 'my-app') + + scaffold({ + projectName: 'my-app', + targetDir: target, + appDir: starter(), + scaffoldDir, + localnet: true, + }) + + const keys = [...read(target, '.env.example').matchAll(/^([A-Z_]+)=/gm)].map(([, key]) => key) + expect(keys).toEqual([...new Set(keys)]) + }) +}) + +describe('the layer rule', () => { + const fakeScaffold = (layers: Record<string, string>): string => { + const dir = path.join(work, 'scaffold') + const write = (file: string, content: string): void => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }) + fs.writeFileSync(path.join(dir, file), content) + } + write('starter/package.json', JSON.stringify({ name: 'starter', scripts: { dev: 'vite' } })) + write('starter/src/App.tsx', 'starter') + write('daml-tooling/scripts/build-dar.mjs', '') + for (const [file, content] of Object.entries(layers)) { + write(file, content) + } + return dir + } + + it('leaves out the DAML tooling when the app has no daml/', () => { + const target = path.join(work, 'out') + + const scaffoldDir = fakeScaffold({}) + scaffold({ + projectName: 'x', + targetDir: target, + appDir: path.join(scaffoldDir, 'starter'), + scaffoldDir, + localnet: false, + }) + + expect(fs.existsSync(path.join(target, 'scripts/build-dar.mjs'))).toBe(false) + }) + + it('refuses a layer that rewrites a file an earlier one wrote', () => { + const scaffoldDir = fakeScaffold({ 'localnet/src/App.tsx': 'overwritten' }) + + expect(() => + scaffold({ + projectName: 'x', + targetDir: path.join(work, 'out'), + appDir: path.join(scaffoldDir, 'starter'), + scaffoldDir, + localnet: true, + }), + ).toThrow(ScaffoldConflictError) + }) + + it('refuses a layer that redefines a script', () => { + const scaffoldDir = fakeScaffold({ + 'localnet/package.json': JSON.stringify({ scripts: { dev: 'something else' } }), + }) + + expect(() => + scaffold({ + projectName: 'x', + targetDir: path.join(work, 'out'), + appDir: path.join(scaffoldDir, 'starter'), + scaffoldDir, + localnet: true, + }), + ).toThrow(/redefines scripts: dev/) + }) + + it('refuses a layer manifest field the merge would drop', () => { + const scaffoldDir = fakeScaffold({ + 'localnet/package.json': JSON.stringify({ engines: { node: '>=24' } }), + }) + + expect(() => + scaffold({ + projectName: 'x', + targetDir: path.join(work, 'out'), + appDir: path.join(scaffoldDir, 'starter'), + scaffoldDir, + localnet: true, + }), + ).toThrow(/would drop: engines/) + }) +}) diff --git a/canton-create/src/scaffold.ts b/canton-create/src/scaffold.ts new file mode 100644 index 00000000..b9228d80 --- /dev/null +++ b/canton-create/src/scaffold.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs' +import path from 'node:path' +import { + APPENDABLE, + finalizeManifest, + mergeEnvExample, + mergeManifests, + ScaffoldConflictError, +} from '#src/merge' + +// npm drops a `.gitignore` from every tarball it packs, and a `pnpm-workspace.yaml` inside the +// monorepo would make pnpm treat the app as its own workspace root, so both travel renamed. +const RENAMES: Record<string, string> = { + _gitignore: '.gitignore', + '_pnpm-workspace.yaml': 'pnpm-workspace.yaml', +} + +const publishedName = (name: string): string => RENAMES[name] ?? name + +const readJson = (file: string): Record<string, unknown> => + JSON.parse(fs.readFileSync(file, 'utf8')) as Record<string, unknown> + +const writeJson = (file: string, value: unknown): void => { + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`) +} + +const walk = (dir: string, prefix = ''): string[] => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const relative = path.join(prefix, entry.name) + return entry.isDirectory() ? walk(path.join(dir, entry.name), relative) : [relative] + }) + +const copyTree = (from: string, to: string): string[] => + walk(from).map((relative) => { + const target = path.join(path.dirname(relative), publishedName(path.basename(relative))) + fs.mkdirSync(path.dirname(path.join(to, target)), { recursive: true }) + fs.copyFileSync(path.join(from, relative), path.join(to, target)) + return target + }) + +const applyFragment = (fragmentDir: string, targetDir: string): string[] => + walk(fragmentDir).flatMap((relative) => { + const source = path.join(fragmentDir, relative) + const target = path.join(path.dirname(relative), publishedName(path.basename(relative))) + const destination = path.join(targetDir, target) + + if (target === 'package.json') { + writeJson(destination, mergeManifests(readJson(destination), readJson(source))) + return [] + } + if (!fs.existsSync(destination)) { + fs.mkdirSync(path.dirname(destination), { recursive: true }) + fs.copyFileSync(source, destination) + return [target] + } + if (path.basename(target) === '.env.example') { + const merged = mergeEnvExample( + fs.readFileSync(destination, 'utf8'), + fs.readFileSync(source, 'utf8'), + ) + fs.writeFileSync(destination, merged) + return [] + } + if (APPENDABLE.has(path.basename(target))) { + fs.appendFileSync(destination, fs.readFileSync(source)) + return [] + } + throw new ScaffoldConflictError(`a layer rewrites a file an earlier one wrote: ${target}`) + }) + +export const hasContract = (dir: string): boolean => + fs.existsSync(path.join(dir, 'daml', 'daml.yaml')) + +export interface ScaffoldOptions { + projectName: string + targetDir: string + appDir: string + scaffoldDir: string + localnet: boolean +} + +/** The app (the starter or an example), then the DAML tooling when it has a contract, then the local network. */ +export const scaffold = ({ + projectName, + targetDir, + appDir, + scaffoldDir, + localnet, +}: ScaffoldOptions): string[] => { + const written = copyTree(appDir, targetDir) + const manifestPath = path.join(targetDir, 'package.json') + if (!fs.existsSync(manifestPath)) { + throw new Error(`the app has no package.json: ${appDir}`) + } + const layers = [ + ...(hasContract(targetDir) ? ['daml-tooling'] : []), + ...(localnet ? ['localnet'] : []), + ] + const added = layers.flatMap((layer) => applyFragment(path.join(scaffoldDir, layer), targetDir)) + writeJson(manifestPath, finalizeManifest(readJson(manifestPath), projectName)) + return [...written, ...added] +} diff --git a/canton-create/src/testing/fixtures/example-hello/_gitignore b/canton-create/src/testing/fixtures/example-hello/_gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/canton-create/src/testing/fixtures/example-hello/_gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/canton-create/src/testing/fixtures/example-hello/package.json b/canton-create/src/testing/fixtures/example-hello/package.json new file mode 100644 index 00000000..1224ee91 --- /dev/null +++ b/canton-create/src/testing/fixtures/example-hello/package.json @@ -0,0 +1,20 @@ +{ + "name": "@bootnodedev/canton-example-hello", + "version": "0.0.1", + "description": "A fixture example", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/BootNodeDev/canton-dappbooster.git" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "_gitignore", + "src" + ], + "scripts": { + "dev": "vite" + } +} diff --git a/canton-create/src/testing/fixtures/example-hello/src/index.ts b/canton-create/src/testing/fixtures/example-hello/src/index.ts new file mode 100644 index 00000000..9b857576 --- /dev/null +++ b/canton-create/src/testing/fixtures/example-hello/src/index.ts @@ -0,0 +1 @@ +export const hello = 'world' diff --git a/canton-create/src/testing/packedScaffold.ts b/canton-create/src/testing/packedScaffold.ts new file mode 100644 index 00000000..3ccfeead --- /dev/null +++ b/canton-create/src/testing/packedScaffold.ts @@ -0,0 +1,24 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +export interface PackedScaffold { + scaffoldDir: string + remove: () => void +} + +/** Packs the CLI as npm would publish it, so a test scaffolds from what `files` ships. */ +export const packScaffold = (): PackedScaffold => { + const packed = fs.mkdtempSync(path.join(os.tmpdir(), 'create-canton-dappbooster-packed-')) + const packageDir = path.resolve(import.meta.dirname, '..', '..') + const args = ['pack', packageDir, '--pack-destination', packed, '--json', '--ignore-scripts'] + const [{ filename }] = JSON.parse(execFileSync('npm', args, { encoding: 'utf8' })) as { + filename: string + }[] + execFileSync('tar', ['-xzf', path.join(packed, filename), '-C', packed]) + return { + scaffoldDir: path.join(packed, 'package', 'scaffold'), + remove: () => fs.rmSync(packed, { recursive: true, force: true }), + } +} diff --git a/canton-create/tsconfig.json b/canton-create/tsconfig.json new file mode 100644 index 00000000..a81f98e5 --- /dev/null +++ b/canton-create/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "jsx": "react-jsx" + }, + "include": ["src", "tsdown.config.ts", "vitest.config.ts"] +} diff --git a/canton-create/tsdown.config.ts b/canton-create/tsdown.config.ts new file mode 100644 index 00000000..12f68676 --- /dev/null +++ b/canton-create/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.tsx'], + format: ['esm'], + platform: 'node', + dts: false, + fixedExtension: false, + minify: true, + // Bundled so `npm create` downloads one file instead of Ink's 25 MB of dependencies. + noExternal: [/.*/], + // Ink imports it only in DEV mode, and only when it is installed. + external: ['react-devtools-core'], +}) diff --git a/canton-create/vitest.config.ts b/canton-create/vitest.config.ts new file mode 100644 index 00000000..e717b720 --- /dev/null +++ b/canton-create/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + // The scaffold's scripts carry node:test files of their own, which the root `pnpm test` runs. + include: ['src/**/*.test.{ts,tsx}'], + // `npm pack` in the example test is a real subprocess. + testTimeout: 60_000, + }, +}) diff --git a/canton-dappbooster/CLAUDE.md b/canton-dappbooster/CLAUDE.md index 1be7fb48..bc44a8ee 100644 --- a/canton-dappbooster/CLAUDE.md +++ b/canton-dappbooster/CLAUDE.md @@ -40,7 +40,7 @@ L2 headless components; styling is L3, in [`canton-theme`](../canton-theme). See ## Authoring a component -Root [`CLAUDE.md`](../CLAUDE.md) owns the general rules under Authoring a Component or Hook, including +Root [`CLAUDE.md`](../CLAUDE.md) owns the general rules under Authoring a component or hook, including accessibility and markup semantics. The styling contract they defer to is this: `node canton-dappbooster/scripts/add-component.mjs <Name>` from the repo root writes steps 1, 2, and @@ -96,10 +96,10 @@ saves the typing. `@bootnodedev/canton-connect` resolves through its `types` entry into `dist/`, which is gitignored, and CI typechecks before it builds. It passes locally either way, because a `dist/` from an earlier build is sitting there. -- Keep this package app-agnostic: do not import from `dapp/`. +- Keep this package app-agnostic: do not import from `example-dapps/`. - React 19 only, peer and dev alike. -- `dapp/frontend` keeps its own copy/check icons in its `src/icons/`. Leave them: the kit's - icons are internal, and exporting them is a public-API decision, not a deduplication chore. +- `example-dapps/amulet-vesting` keeps its own copy/check icons in its `src/icons/`. Leave them: the + kit's icons are internal, and exporting them is a public-API decision, not a deduplication chore. ## Testing diff --git a/canton-dappbooster/doc-fixtures.d.ts b/canton-dappbooster/doc-fixtures.d.ts index 6736bf0e..bf931945 100644 --- a/canton-dappbooster/doc-fixtures.d.ts +++ b/canton-dappbooster/doc-fixtures.d.ts @@ -1,4 +1,4 @@ -// The placeholder vocabulary an @example may lean on. `kit/docs-check.mjs` compiles every +// The placeholder vocabulary an @example may lean on. `scripts/docs-check.mjs` compiles every // snippet with this file as a root, so an example may use only what is declared here, and a // placeholder's type is the real one — a loosened signature here would hide a broken example. // diff --git a/canton-dappbooster/typedoc.json b/canton-dappbooster/typedoc.json index 50c7e176..977e24ec 100644 --- a/canton-dappbooster/typedoc.json +++ b/canton-dappbooster/typedoc.json @@ -1,6 +1,6 @@ { "$schema": "https://typedoc.org/schema.json", - "extends": ["../kit/typedoc.shared.json"], + "extends": ["../scripts/typedoc.shared.json"], "entryPoints": ["src/index.ts", "src/connect.ts"], "categoryOrder": ["Components", "Hooks", "Types", "Utilities", "*"], "defaultCategory": "Types", diff --git a/canton-theme/CLAUDE.md b/canton-theme/CLAUDE.md index cf31bdc1..f259d88c 100644 --- a/canton-theme/CLAUDE.md +++ b/canton-theme/CLAUDE.md @@ -175,7 +175,7 @@ exactly the case the attribute exists for. `display: none` in the user-agent stylesheet, which any author `display` loses to whatever the layer or the specificity. So a part with its own `display` keeps the closed panel on screen for a consumer whose reset does not re-declare `[hidden]`; ours only looked right because - `dapp/frontend` pulls in Tailwind's preflight. + `example-dapps/amulet-vesting` pulls in Tailwind's preflight. - The dialog's `z-index` goes on its positioner and its backdrop, which is only safe because its machine does not use Zag's popper. A popper-positioned part takes it on the *content* instead: the popper owns the positioner's inline style and copies the content's computed `z-index` onto it as @@ -196,5 +196,5 @@ exactly the case the attribute exists for. ## Validation - `pnpm lint` from the repo root (Biome checks CSS; there is no local Biome config). -- Kit components render against this theme in `dapp/frontend` on port 3012, which is where a - palette change gets looked at. +- Kit components render against this theme in `example-dapps/amulet-vesting` on port 3012, which is + where a palette change gets looked at. diff --git a/dapp/README.md b/dapp/README.md deleted file mode 100644 index 06e2362c..00000000 --- a/dapp/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# dApp - -Minimal dApp \ No newline at end of file diff --git a/dapp/daml/package.json b/dapp/daml/package.json deleted file mode 100644 index 21e30728..00000000 --- a/dapp/daml/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@canton-dappbooster/daml", - "private": true, - "version": "0.3.0", - "scripts": { - "build": "node ../../scripts/fetch-daml-deps.mjs && node ../../scripts/build-dar.mjs" - } -} diff --git a/dapp/frontend/.gitignore b/dapp/frontend/.gitignore deleted file mode 100644 index 77c1cef3..00000000 --- a/dapp/frontend/.gitignore +++ /dev/null @@ -1,51 +0,0 @@ -# Dependencies -node_modules -.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# Build output -dist -dist-ssr -build -*.local - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -# Env -.env -.env.* -!.env.example - -# Editor / OS -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - -# Caches -.cache -.eslintcache -*.tsbuildinfo - -# Project-specific -.mockups -.playwright-mcp -docs -.vercel diff --git a/dapp/frontend/README.md b/dapp/frontend/README.md deleted file mode 100644 index 40a6d5f1..00000000 --- a/dapp/frontend/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# Amulet vesting dApp - -`@canton-dappbooster/frontend` is a dApp for Amulet vesting. A funder proposes a grant and the -receiver accepts it. The receiver claims Amulet as the grant vests. If the funder cancels the grant, -the receiver keeps the vested but unclaimed Amulet as a residual claim. - -Acceptance locks the funder's Amulet in escrow, and each claim releases part of it. The figures on -screen are therefore real holdings. Each grant shows its vested and claimable amounts live, and -[`src/utils/schedule.ts`](src/utils/schedule.ts) computes them. - -Every read and every write goes through the connected CIP-0103 wallet. The app acts as the wallet's -primary account, and each write raises a real approval prompt. There is no mock mode. If the ledger -has no vesting operator and factory, or you have not connected a wallet, the pages show a connect -placeholder. See the root [README](../../README.md) for the wider stack. - -> This frontend comes from `cn-dappbooster@feat/vesting-lite`. See -> [`PROVENANCE.md`](PROVENANCE.md). The Daml package it uses is in [`../daml`](../daml). - -## Run the app - -To start everything this app needs, and the app itself, run `./scripts/dev-stack.sh` from the repo -root. The root [README](../../README.md) describes it. - -To start the pieces by hand, start a Canton LocalNet first. Then run these commands from the repo -root. A single `pnpm install` at the root links every workspace. - -```bash -pnpm run mint-token # prints a CANTON_BACKEND_TOKEN line; add it to .env -pnpm run build-dar -# <version> is the one in dapp/daml/daml.yaml -pnpm run deploy-dar -- dapp/daml/.daml/dist/amulet-vesting-<version>.dar -pnpm run bootstrap # creates the operator and its factory -pnpm run wallet-gateway # serves http://localhost:3030 -pnpm run app:dev # serves http://localhost:3012 -``` - -Each `bootstrap` run creates a new operator and factory, and the dApp uses the newest. It writes no -file. [`architecture.md`](architecture.md) explains how the dApp finds them on the ledger. - -## Get Amulet and connect a wallet - -Funding a grant takes Amulet. The **Tap Amulet** faucet in the account menu taps 100 AMT into the -connected party. It works on LocalNet and DevNet, where `AmuletRules_DevNet_Tap` exists. It fails -until the first mining round opens, about ten minutes after a fresh LocalNet start. - -Connect with a CIP-0103 wallet. The dev stack starts Wallet Gateway on http://localhost:3030. When -you create a party in Wallet Gateway, choose the `wallet-kernel` signing provider. With -`participant`, the validator merges the party's Amulets, and accepting a pending grant then fails. - -You act as the party the wallet reports, and the wallet restores the session on reload. If you -change the wallet's primary party, the dApp acts as the new one. - -## Configure the app - -The app reads its variables from the repo root's `.env`, and the root -[`.env.example`](../../.env.example) lists them. The URL variables default to the local stack. - -WalletConnect is off unless you set `VITE_WALLET_CONNECT_PROJECT_ID` to a Reown project id. Once you -set it, `VITE_NETWORK_ID` must match the network the wallet serves. For the local gateway, that is -`canton:localnet`. - -## How it fits together - -[`architecture.md`](architecture.md) describes the app's internal structure and the reasoning -behind it. diff --git a/.env.example b/example-dapps/amulet-vesting/.env.example similarity index 78% rename from .env.example rename to example-dapps/amulet-vesting/.env.example index bb32d5b2..668484be 100644 --- a/.env.example +++ b/example-dapps/amulet-vesting/.env.example @@ -11,9 +11,15 @@ SPLICE_REGISTRY_API_URL=http://localhost:2000/api/validator/v0/scan-proxy CANTON_AUTH_AUDIENCE=https://canton.network.global # Never copy CANTON_AUTH_SECRET into a browser. CANTON_AUTH_SECRET=unsafe -# generate with `node scripts/mint-token.mjs ledger-api-user` +# On a LocalNet the stack fills this in; or run `pnpm mint-token` CANTON_BACKEND_TOKEN= +############################################################################### +# DAML build (pnpm build-dar) +############################################################################### +# Without the local network, the Splice release your network runs, e.g. 0.8.1 +# SPLICE_TAG= + ############################################################################### # Frontend ############################################################################### diff --git a/example-dapps/amulet-vesting/AGENTS.md b/example-dapps/amulet-vesting/AGENTS.md new file mode 100644 index 00000000..a7afa29b --- /dev/null +++ b/example-dapps/amulet-vesting/AGENTS.md @@ -0,0 +1,3 @@ +# Agent Configuration +This project's canonical agent configuration lives in [`CLAUDE.md`](./CLAUDE.md). +Agents read `AGENTS.md` natively; load `CLAUDE.md` as the single source of truth. diff --git a/dapp/frontend/CLAUDE.md b/example-dapps/amulet-vesting/CLAUDE.md similarity index 94% rename from dapp/frontend/CLAUDE.md rename to example-dapps/amulet-vesting/CLAUDE.md index 1e8fa2c7..5730f9ca 100644 --- a/dapp/frontend/CLAUDE.md +++ b/example-dapps/amulet-vesting/CLAUDE.md @@ -1,8 +1,9 @@ -# Agent Configuration — dapp/frontend +# Agent Configuration — example-dapps/amulet-vesting -This file applies only to `dapp/frontend/`. For monorepo-wide rules, including the filename casing -table and the colocate-then-promote placement rules it layers on, see [`../../CLAUDE.md`](../../CLAUDE.md). -The internal seams are in [`architecture.md`](architecture.md). Deltas for this app only are below. +This file applies only to `example-dapps/amulet-vesting/`. For monorepo-wide rules, including the +filename casing table and the colocate-then-promote placement rules it layers on, see +[`../../CLAUDE.md`](../../CLAUDE.md). The internal seams are in +[`architecture.md`](architecture.md). Deltas for this app only are below. ## Layout diff --git a/dapp/frontend/PROVENANCE.md b/example-dapps/amulet-vesting/PROVENANCE.md similarity index 97% rename from dapp/frontend/PROVENANCE.md rename to example-dapps/amulet-vesting/PROVENANCE.md index 557015cf..09a8d41a 100644 --- a/dapp/frontend/PROVENANCE.md +++ b/example-dapps/amulet-vesting/PROVENANCE.md @@ -23,7 +23,7 @@ Applied on import to fit this repo's conventions: `AGENTS.md`, `architecture.md`, and `.nvmrc`; the repo uses one root Biome config and root Node pin. The `architecture.md` here now is this repo's own, not that one. The source's `noImportantStyles`/`noDescendingSpecificity` CSS relaxations - moved into the root `biome.json` `dapp/frontend` override. + moved into the root `biome.json` override for `example-dapps/`. - **Toolchain aligned to the repo:** `vite` 6→8, `@vitejs/plugin-react` 4→6, `vitest` 3→4 (avoids duplicate major versions across the workspace). - **Kit-from-source resolution:** `tsconfig.app.json` gains diff --git a/example-dapps/amulet-vesting/README.md b/example-dapps/amulet-vesting/README.md new file mode 100644 index 00000000..e9c3ab4c --- /dev/null +++ b/example-dapps/amulet-vesting/README.md @@ -0,0 +1,66 @@ +# Amulet vesting dApp + +`@bootnodedev/canton-example-amulet-vesting` is a dApp for Amulet vesting. A funder proposes a grant and the +receiver accepts it. The receiver claims Amulet as the grant vests. If the funder cancels the grant, +the receiver keeps the vested but unclaimed Amulet as a residual claim. + +Acceptance locks the funder's Amulet in escrow, and each claim releases part of it. The figures on +screen are therefore real holdings. Each grant shows its vested and claimable amounts live, and +[`src/utils/schedule.ts`](src/utils/schedule.ts) computes them. + +Every read and every write goes through the connected CIP-0103 wallet. The app acts as the wallet's +primary account, and each write raises a real approval prompt. There is no mock mode. If the ledger +has no vesting operator and factory, or you have not connected a wallet, the pages show a connect +placeholder. + +> This frontend comes from `cn-dappbooster@feat/vesting-lite`. See +> [`PROVENANCE.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/PROVENANCE.md). +> The Daml package it uses is in [`daml/`](daml/). + +## Run the app + +`create-canton-dappbooster --example amulet-vesting` makes a project from this package. With +`--localnet`, `pnpm stack up` starts everything the app needs and the app itself. The installer +appends a Local network section to this README that describes it. + +Without the local network, point `.env` at your network first: `CANTON_JSON_API_URL`, +`CANTON_BACKEND_TOKEN`, the `VITE_` URLs, and `SPLICE_TAG` for the contract build. Then build and +deploy the contract as the Contract section the installer appends describes, and run: + +```bash +pnpm bootstrap # creates the operator and its factory +pnpm dev # serves http://localhost:3012 +``` + +Each `bootstrap` run creates a new operator and factory, and the dApp uses the newest. It writes no +file. +[`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/architecture.md) +explains how the dApp finds them on the ledger. + +## Get Amulet and connect a wallet + +Funding a grant takes Amulet. The **Tap Amulet** faucet in the account menu taps 100 AMT into the +connected party. It works on LocalNet and DevNet, where `AmuletRules_DevNet_Tap` exists. It fails +until the first mining round opens, about ten minutes after a fresh LocalNet start. + +Connect with a CIP-0103 wallet. With the local network, `pnpm stack up` starts Wallet Gateway on +http://localhost:3030. When you create a party in Wallet Gateway, choose the `wallet-kernel` +signing provider. With `participant`, the validator merges the party's Amulets, and accepting a +pending grant then fails. + +You act as the party the wallet reports, and the wallet restores the session on reload. If you +change the wallet's primary party, the dApp acts as the new one. + +## Configure the app + +The app reads its variables from `.env` in the project folder, and [`.env.example`](.env.example) +lists them. The URL variables default to the local stack. + +WalletConnect is off unless you set `VITE_WALLET_CONNECT_PROJECT_ID` to a Reown project id. Once you +set it, `VITE_NETWORK_ID` must match the network the wallet serves. For the local gateway, that is +`canton:localnet`. + +## How it fits together + +[`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/example-dapps/amulet-vesting/architecture.md) +describes the app's internal structure and the reasoning behind it. diff --git a/example-dapps/amulet-vesting/_gitignore b/example-dapps/amulet-vesting/_gitignore new file mode 100644 index 00000000..f86dd145 --- /dev/null +++ b/example-dapps/amulet-vesting/_gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.tsbuildinfo +.env +.env.* +!.env.example +.DS_Store diff --git a/example-dapps/amulet-vesting/_pnpm-workspace.yaml b/example-dapps/amulet-vesting/_pnpm-workspace.yaml new file mode 100644 index 00000000..ea4c00ab --- /dev/null +++ b/example-dapps/amulet-vesting/_pnpm-workspace.yaml @@ -0,0 +1,8 @@ +# pnpm 10+ refuses to run a dependency's build script until it is listed here. protobufjs, under +# the Canton SDK, has one; the other four come with the local network's packages. +allowBuilds: + protobufjs: true + better-sqlite3: true + cbor-extract: true + esbuild: true + secp256k1: true diff --git a/dapp/frontend/architecture.md b/example-dapps/amulet-vesting/architecture.md similarity index 99% rename from dapp/frontend/architecture.md rename to example-dapps/amulet-vesting/architecture.md index 3361bf5f..0d7d59a2 100644 --- a/dapp/frontend/architecture.md +++ b/example-dapps/amulet-vesting/architecture.md @@ -414,7 +414,7 @@ serves a list. **The registry is reached through the dev server, not directly.** LocalNet serves it under the validator's authenticated prefix, so a browser gets a 401. `REGISTRY_URL` is therefore the same-origin `/registry`, and [`vite.config.ts`](vite.config.ts) proxies that to -`SPLICE_REGISTRY_API_URL` with the bearer from the root `.env`. The token stays in the dev server +`SPLICE_REGISTRY_API_URL` with the bearer from `.env`. The token stays in the dev server and never reaches the bundle. It is a constant rather than a `VITE_` variable because every build has the same value for it; a deployment pointing at another registry is what would earn the variable, and that deployment does not work yet — the proxy is the dev server's, so a deployed build @@ -499,7 +499,7 @@ That literal is the build's doing. [`vite.config.ts`](vite.config.ts) runs `parseEnv(loadEnv(...))` and `define`s the parsed values back onto `import.meta.env`, so a bad `VITE_EXPLORER_URL` fails the build rather than the page load and the client ships no validator at all. [`src/utils/env.ts`](src/utils/env.ts) holds that contract, and is the only module under `src/` -that runs outside the browser. The `.env` it reads is the repo root's, the one file the monorepo +that runs outside the browser. The `.env` it reads is this folder's, the one file the monorepo keeps, and it is loaded with an empty prefix — every key in it, `CANTON_AUTH_SECRET` included — so only what `parseEnv` returns may be defined back. Spreading the loaded object would put the signing secret in the bundle. diff --git a/dapp/daml/.gitignore b/example-dapps/amulet-vesting/daml/.gitignore similarity index 100% rename from dapp/daml/.gitignore rename to example-dapps/amulet-vesting/daml/.gitignore diff --git a/dapp/daml/AmuletVesting.daml b/example-dapps/amulet-vesting/daml/AmuletVesting.daml similarity index 100% rename from dapp/daml/AmuletVesting.daml rename to example-dapps/amulet-vesting/daml/AmuletVesting.daml diff --git a/dapp/daml/AmuletVesting/Schedule.daml b/example-dapps/amulet-vesting/daml/AmuletVesting/Schedule.daml similarity index 100% rename from dapp/daml/AmuletVesting/Schedule.daml rename to example-dapps/amulet-vesting/daml/AmuletVesting/Schedule.daml diff --git a/dapp/daml/PROVENANCE.md b/example-dapps/amulet-vesting/daml/PROVENANCE.md similarity index 94% rename from dapp/daml/PROVENANCE.md rename to example-dapps/amulet-vesting/daml/PROVENANCE.md index 50c91479..7ae5d051 100644 --- a/dapp/daml/PROVENANCE.md +++ b/example-dapps/amulet-vesting/daml/PROVENANCE.md @@ -9,11 +9,11 @@ fetched rather than committed (see [`README.md`](README.md)). | Source repo | https://github.com/BootNodeDev/cc-vesting-contracts | | Source branch | `main` | | Source commit | `000c481b1c9119bf564568c93ab53cddd3072ab0` | -| Imported | `daml/amulet-vesting/daml/` flattened into `dapp/daml/` | +| Imported | `daml/amulet-vesting/daml/` flattened into `dapp/daml/`, since moved to `example-dapps/amulet-vesting/daml/` | ## Integration deltas -- **The package sits at `dapp/daml/` rather than in a folder named after itself, and `daml.yaml` +- **The package sits directly in `daml/` rather than in a folder named after itself, and `daml.yaml` reads `source: .` rather than `source: daml`.** Upstream is a container of several dpm packages and needs the name in the path; there is one package here, so a rename would otherwise move every reference to it. The LF package id covers the relative source root, so this layout is what diff --git a/dapp/daml/README.md b/example-dapps/amulet-vesting/daml/README.md similarity index 86% rename from dapp/daml/README.md rename to example-dapps/amulet-vesting/daml/README.md index 41edfee2..dcd60be2 100644 --- a/dapp/daml/README.md +++ b/example-dapps/amulet-vesting/daml/README.md @@ -12,12 +12,12 @@ From the repo root: ```bash pnpm run build-dar -pnpm run deploy-dar -- dapp/daml/.daml/dist/amulet-vesting-0.0.3.dar +pnpm run deploy-dar -- example-dapps/amulet-vesting/daml/.daml/dist/amulet-vesting-0.0.3.dar ``` -`deploy-dar` uploads to the app-user JSON API and takes the bearer token from the root `.env`. -Both commands need the Daml SDK (`dpm`), which `scripts/dev-stack.sh up` checks for before it -starts anything. +`deploy-dar` uploads to the app-user JSON API and takes the bearer token from +`example-dapps/amulet-vesting/.env`. Both commands need the Daml SDK (`dpm`), which +`scripts/dev-stack.sh up` checks for before it starts anything. Any edit to the model changes the package id, and a participant refuses a second package at the same name and version — `KNOWN_PACKAGE_VERSION`. So a redeploy after a source change means either diff --git a/dapp/daml/daml.yaml b/example-dapps/amulet-vesting/daml/daml.yaml similarity index 100% rename from dapp/daml/daml.yaml rename to example-dapps/amulet-vesting/daml/daml.yaml diff --git a/dapp/frontend/index.html b/example-dapps/amulet-vesting/index.html similarity index 100% rename from dapp/frontend/index.html rename to example-dapps/amulet-vesting/index.html diff --git a/dapp/frontend/package.json b/example-dapps/amulet-vesting/package.json similarity index 55% rename from dapp/frontend/package.json rename to example-dapps/amulet-vesting/package.json index 77667d40..feb33425 100644 --- a/dapp/frontend/package.json +++ b/example-dapps/amulet-vesting/package.json @@ -1,8 +1,35 @@ { - "name": "@canton-dappbooster/frontend", - "private": true, - "version": "0.3.0", + "name": "@bootnodedev/canton-example-amulet-vesting", + "version": "0.5.6", + "description": "Canton Coin vesting dApp: an example app for create-canton-dappbooster", + "repository": { + "type": "git", + "url": "git+https://github.com/BootNodeDev/canton-dappbooster.git", + "directory": "example-dapps/amulet-vesting" + }, + "license": "MIT", "type": "module", + "private": false, + "publishConfig": { + "access": "public" + }, + "files": [ + "index.html", + "public", + "src", + "daml/AmuletVesting.daml", + "daml/AmuletVesting", + "daml/daml.yaml", + "daml/README.md", + "scripts", + "tsconfig.json", + "tsconfig.app.json", + "tsconfig.node.json", + "vite.config.ts", + ".env.example", + "_gitignore", + "_pnpm-workspace.yaml" + ], "engines": { "node": ">=24.15.0" }, @@ -12,9 +39,8 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "lint": "biome check", - "lint:fix": "biome check --write", - "typecheck": "tsc -b --noEmit" + "typecheck": "tsc -b --noEmit", + "bootstrap": "node scripts/bootstrap-vesting.mjs" }, "dependencies": { "@ark-ui/react": "^5.39.1", diff --git a/dapp/frontend/public/favicon.svg b/example-dapps/amulet-vesting/public/favicon.svg similarity index 100% rename from dapp/frontend/public/favicon.svg rename to example-dapps/amulet-vesting/public/favicon.svg diff --git a/scripts/bootstrap-vesting.mjs b/example-dapps/amulet-vesting/scripts/bootstrap-vesting.mjs similarity index 100% rename from scripts/bootstrap-vesting.mjs rename to example-dapps/amulet-vesting/scripts/bootstrap-vesting.mjs diff --git a/dapp/frontend/src/App.tsx b/example-dapps/amulet-vesting/src/App.tsx similarity index 100% rename from dapp/frontend/src/App.tsx rename to example-dapps/amulet-vesting/src/App.tsx diff --git a/dapp/frontend/src/assets/canton-coin.png b/example-dapps/amulet-vesting/src/assets/canton-coin.png similarity index 100% rename from dapp/frontend/src/assets/canton-coin.png rename to example-dapps/amulet-vesting/src/assets/canton-coin.png diff --git a/dapp/frontend/src/assets/gateway-login.png b/example-dapps/amulet-vesting/src/assets/gateway-login.png similarity index 100% rename from dapp/frontend/src/assets/gateway-login.png rename to example-dapps/amulet-vesting/src/assets/gateway-login.png diff --git a/dapp/frontend/src/assets/gateway-parties.png b/example-dapps/amulet-vesting/src/assets/gateway-parties.png similarity index 100% rename from dapp/frontend/src/assets/gateway-parties.png rename to example-dapps/amulet-vesting/src/assets/gateway-parties.png diff --git a/dapp/frontend/src/assets/wallet-picker.png b/example-dapps/amulet-vesting/src/assets/wallet-picker.png similarity index 100% rename from dapp/frontend/src/assets/wallet-picker.png rename to example-dapps/amulet-vesting/src/assets/wallet-picker.png diff --git a/dapp/frontend/src/backend/LedgerBackend.test.ts b/example-dapps/amulet-vesting/src/backend/LedgerBackend.test.ts similarity index 100% rename from dapp/frontend/src/backend/LedgerBackend.test.ts rename to example-dapps/amulet-vesting/src/backend/LedgerBackend.test.ts diff --git a/dapp/frontend/src/backend/LedgerBackend.ts b/example-dapps/amulet-vesting/src/backend/LedgerBackend.ts similarity index 100% rename from dapp/frontend/src/backend/LedgerBackend.ts rename to example-dapps/amulet-vesting/src/backend/LedgerBackend.ts diff --git a/dapp/frontend/src/backend/VestingBackend.test.ts b/example-dapps/amulet-vesting/src/backend/VestingBackend.test.ts similarity index 100% rename from dapp/frontend/src/backend/VestingBackend.test.ts rename to example-dapps/amulet-vesting/src/backend/VestingBackend.test.ts diff --git a/dapp/frontend/src/backend/VestingBackend.ts b/example-dapps/amulet-vesting/src/backend/VestingBackend.ts similarity index 100% rename from dapp/frontend/src/backend/VestingBackend.ts rename to example-dapps/amulet-vesting/src/backend/VestingBackend.ts diff --git a/dapp/frontend/src/backend/commands.test.ts b/example-dapps/amulet-vesting/src/backend/commands.test.ts similarity index 100% rename from dapp/frontend/src/backend/commands.test.ts rename to example-dapps/amulet-vesting/src/backend/commands.test.ts diff --git a/dapp/frontend/src/backend/commands.ts b/example-dapps/amulet-vesting/src/backend/commands.ts similarity index 100% rename from dapp/frontend/src/backend/commands.ts rename to example-dapps/amulet-vesting/src/backend/commands.ts diff --git a/dapp/frontend/src/backend/config.test.ts b/example-dapps/amulet-vesting/src/backend/config.test.ts similarity index 100% rename from dapp/frontend/src/backend/config.test.ts rename to example-dapps/amulet-vesting/src/backend/config.test.ts diff --git a/dapp/frontend/src/backend/config.ts b/example-dapps/amulet-vesting/src/backend/config.ts similarity index 100% rename from dapp/frontend/src/backend/config.ts rename to example-dapps/amulet-vesting/src/backend/config.ts diff --git a/dapp/frontend/src/backend/synchronizer.test.ts b/example-dapps/amulet-vesting/src/backend/synchronizer.test.ts similarity index 100% rename from dapp/frontend/src/backend/synchronizer.test.ts rename to example-dapps/amulet-vesting/src/backend/synchronizer.test.ts diff --git a/dapp/frontend/src/backend/synchronizer.ts b/example-dapps/amulet-vesting/src/backend/synchronizer.ts similarity index 100% rename from dapp/frontend/src/backend/synchronizer.ts rename to example-dapps/amulet-vesting/src/backend/synchronizer.ts diff --git a/dapp/frontend/src/backend/transferContext.test.ts b/example-dapps/amulet-vesting/src/backend/transferContext.test.ts similarity index 100% rename from dapp/frontend/src/backend/transferContext.test.ts rename to example-dapps/amulet-vesting/src/backend/transferContext.test.ts diff --git a/dapp/frontend/src/backend/transferContext.ts b/example-dapps/amulet-vesting/src/backend/transferContext.ts similarity index 100% rename from dapp/frontend/src/backend/transferContext.ts rename to example-dapps/amulet-vesting/src/backend/transferContext.ts diff --git a/dapp/frontend/src/backend/wallet.ts b/example-dapps/amulet-vesting/src/backend/wallet.ts similarity index 100% rename from dapp/frontend/src/backend/wallet.ts rename to example-dapps/amulet-vesting/src/backend/wallet.ts diff --git a/dapp/frontend/src/components/AmountDisplay.tsx b/example-dapps/amulet-vesting/src/components/AmountDisplay.tsx similarity index 100% rename from dapp/frontend/src/components/AmountDisplay.tsx rename to example-dapps/amulet-vesting/src/components/AmountDisplay.tsx diff --git a/dapp/frontend/src/components/AppShell.test.tsx b/example-dapps/amulet-vesting/src/components/AppShell.test.tsx similarity index 100% rename from dapp/frontend/src/components/AppShell.test.tsx rename to example-dapps/amulet-vesting/src/components/AppShell.test.tsx diff --git a/dapp/frontend/src/components/AppShell.tsx b/example-dapps/amulet-vesting/src/components/AppShell.tsx similarity index 100% rename from dapp/frontend/src/components/AppShell.tsx rename to example-dapps/amulet-vesting/src/components/AppShell.tsx diff --git a/dapp/frontend/src/components/Button.tsx b/example-dapps/amulet-vesting/src/components/Button.tsx similarity index 100% rename from dapp/frontend/src/components/Button.tsx rename to example-dapps/amulet-vesting/src/components/Button.tsx diff --git a/dapp/frontend/src/components/CancelGrant.tsx b/example-dapps/amulet-vesting/src/components/CancelGrant.tsx similarity index 100% rename from dapp/frontend/src/components/CancelGrant.tsx rename to example-dapps/amulet-vesting/src/components/CancelGrant.tsx diff --git a/dapp/frontend/src/components/Card.tsx b/example-dapps/amulet-vesting/src/components/Card.tsx similarity index 100% rename from dapp/frontend/src/components/Card.tsx rename to example-dapps/amulet-vesting/src/components/Card.tsx diff --git a/dapp/frontend/src/components/Claim.tsx b/example-dapps/amulet-vesting/src/components/Claim.tsx similarity index 100% rename from dapp/frontend/src/components/Claim.tsx rename to example-dapps/amulet-vesting/src/components/Claim.tsx diff --git a/dapp/frontend/src/components/CompactAmount.tsx b/example-dapps/amulet-vesting/src/components/CompactAmount.tsx similarity index 100% rename from dapp/frontend/src/components/CompactAmount.tsx rename to example-dapps/amulet-vesting/src/components/CompactAmount.tsx diff --git a/dapp/frontend/src/components/ConnectFace.tsx b/example-dapps/amulet-vesting/src/components/ConnectFace.tsx similarity index 100% rename from dapp/frontend/src/components/ConnectFace.tsx rename to example-dapps/amulet-vesting/src/components/ConnectFace.tsx diff --git a/dapp/frontend/src/components/ConnectPrompt.tsx b/example-dapps/amulet-vesting/src/components/ConnectPrompt.tsx similarity index 100% rename from dapp/frontend/src/components/ConnectPrompt.tsx rename to example-dapps/amulet-vesting/src/components/ConnectPrompt.tsx diff --git a/dapp/frontend/src/components/CopyButton.tsx b/example-dapps/amulet-vesting/src/components/CopyButton.tsx similarity index 100% rename from dapp/frontend/src/components/CopyButton.tsx rename to example-dapps/amulet-vesting/src/components/CopyButton.tsx diff --git a/dapp/frontend/src/components/CounterpartyId.tsx b/example-dapps/amulet-vesting/src/components/CounterpartyId.tsx similarity index 100% rename from dapp/frontend/src/components/CounterpartyId.tsx rename to example-dapps/amulet-vesting/src/components/CounterpartyId.tsx diff --git a/dapp/frontend/src/components/CreateGrant/DateField.tsx b/example-dapps/amulet-vesting/src/components/CreateGrant/DateField.tsx similarity index 100% rename from dapp/frontend/src/components/CreateGrant/DateField.tsx rename to example-dapps/amulet-vesting/src/components/CreateGrant/DateField.tsx diff --git a/dapp/frontend/src/components/CreateGrant/Details.tsx b/example-dapps/amulet-vesting/src/components/CreateGrant/Details.tsx similarity index 100% rename from dapp/frontend/src/components/CreateGrant/Details.tsx rename to example-dapps/amulet-vesting/src/components/CreateGrant/Details.tsx diff --git a/dapp/frontend/src/components/CreateGrant/LiveScheduleCurve.tsx b/example-dapps/amulet-vesting/src/components/CreateGrant/LiveScheduleCurve.tsx similarity index 100% rename from dapp/frontend/src/components/CreateGrant/LiveScheduleCurve.tsx rename to example-dapps/amulet-vesting/src/components/CreateGrant/LiveScheduleCurve.tsx diff --git a/dapp/frontend/src/components/CreateGrant/Milestones.tsx b/example-dapps/amulet-vesting/src/components/CreateGrant/Milestones.tsx similarity index 100% rename from dapp/frontend/src/components/CreateGrant/Milestones.tsx rename to example-dapps/amulet-vesting/src/components/CreateGrant/Milestones.tsx diff --git a/dapp/frontend/src/components/CreateGrant/Preview.tsx b/example-dapps/amulet-vesting/src/components/CreateGrant/Preview.tsx similarity index 100% rename from dapp/frontend/src/components/CreateGrant/Preview.tsx rename to example-dapps/amulet-vesting/src/components/CreateGrant/Preview.tsx diff --git a/dapp/frontend/src/components/CreateGrant/Schedule.tsx b/example-dapps/amulet-vesting/src/components/CreateGrant/Schedule.tsx similarity index 100% rename from dapp/frontend/src/components/CreateGrant/Schedule.tsx rename to example-dapps/amulet-vesting/src/components/CreateGrant/Schedule.tsx diff --git a/dapp/frontend/src/components/CreateGrant/fields.ts b/example-dapps/amulet-vesting/src/components/CreateGrant/fields.ts similarity index 100% rename from dapp/frontend/src/components/CreateGrant/fields.ts rename to example-dapps/amulet-vesting/src/components/CreateGrant/fields.ts diff --git a/dapp/frontend/src/components/CreateGrant/index.tsx b/example-dapps/amulet-vesting/src/components/CreateGrant/index.tsx similarity index 100% rename from dapp/frontend/src/components/CreateGrant/index.tsx rename to example-dapps/amulet-vesting/src/components/CreateGrant/index.tsx diff --git a/dapp/frontend/src/components/CreateGrant/scheduleForm.test.ts b/example-dapps/amulet-vesting/src/components/CreateGrant/scheduleForm.test.ts similarity index 100% rename from dapp/frontend/src/components/CreateGrant/scheduleForm.test.ts rename to example-dapps/amulet-vesting/src/components/CreateGrant/scheduleForm.test.ts diff --git a/dapp/frontend/src/components/CreateGrant/scheduleForm.ts b/example-dapps/amulet-vesting/src/components/CreateGrant/scheduleForm.ts similarity index 100% rename from dapp/frontend/src/components/CreateGrant/scheduleForm.ts rename to example-dapps/amulet-vesting/src/components/CreateGrant/scheduleForm.ts diff --git a/dapp/frontend/src/components/CreateGrant/useFundingToken.ts b/example-dapps/amulet-vesting/src/components/CreateGrant/useFundingToken.ts similarity index 100% rename from dapp/frontend/src/components/CreateGrant/useFundingToken.ts rename to example-dapps/amulet-vesting/src/components/CreateGrant/useFundingToken.ts diff --git a/dapp/frontend/src/components/CurvePill.tsx b/example-dapps/amulet-vesting/src/components/CurvePill.tsx similarity index 100% rename from dapp/frontend/src/components/CurvePill.tsx rename to example-dapps/amulet-vesting/src/components/CurvePill.tsx diff --git a/dapp/frontend/src/components/EmptyState.tsx b/example-dapps/amulet-vesting/src/components/EmptyState.tsx similarity index 100% rename from dapp/frontend/src/components/EmptyState.tsx rename to example-dapps/amulet-vesting/src/components/EmptyState.tsx diff --git a/dapp/frontend/src/components/ErrorScreen.tsx b/example-dapps/amulet-vesting/src/components/ErrorScreen.tsx similarity index 100% rename from dapp/frontend/src/components/ErrorScreen.tsx rename to example-dapps/amulet-vesting/src/components/ErrorScreen.tsx diff --git a/dapp/frontend/src/components/FieldError.tsx b/example-dapps/amulet-vesting/src/components/FieldError.tsx similarity index 100% rename from dapp/frontend/src/components/FieldError.tsx rename to example-dapps/amulet-vesting/src/components/FieldError.tsx diff --git a/dapp/frontend/src/components/Footer.tsx b/example-dapps/amulet-vesting/src/components/Footer.tsx similarity index 100% rename from dapp/frontend/src/components/Footer.tsx rename to example-dapps/amulet-vesting/src/components/Footer.tsx diff --git a/dapp/frontend/src/components/GrantLock.tsx b/example-dapps/amulet-vesting/src/components/GrantLock.tsx similarity index 100% rename from dapp/frontend/src/components/GrantLock.tsx rename to example-dapps/amulet-vesting/src/components/GrantLock.tsx diff --git a/dapp/frontend/src/components/GrantStatusPill.tsx b/example-dapps/amulet-vesting/src/components/GrantStatusPill.tsx similarity index 100% rename from dapp/frontend/src/components/GrantStatusPill.tsx rename to example-dapps/amulet-vesting/src/components/GrantStatusPill.tsx diff --git a/dapp/frontend/src/components/InfoTip.tsx b/example-dapps/amulet-vesting/src/components/InfoTip.tsx similarity index 100% rename from dapp/frontend/src/components/InfoTip.tsx rename to example-dapps/amulet-vesting/src/components/InfoTip.tsx diff --git a/dapp/frontend/src/components/KpiCard.tsx b/example-dapps/amulet-vesting/src/components/KpiCard.tsx similarity index 100% rename from dapp/frontend/src/components/KpiCard.tsx rename to example-dapps/amulet-vesting/src/components/KpiCard.tsx diff --git a/dapp/frontend/src/components/Loading.tsx b/example-dapps/amulet-vesting/src/components/Loading.tsx similarity index 100% rename from dapp/frontend/src/components/Loading.tsx rename to example-dapps/amulet-vesting/src/components/Loading.tsx diff --git a/dapp/frontend/src/components/Modal.tsx b/example-dapps/amulet-vesting/src/components/Modal.tsx similarity index 100% rename from dapp/frontend/src/components/Modal.tsx rename to example-dapps/amulet-vesting/src/components/Modal.tsx diff --git a/dapp/frontend/src/components/PageTitle.tsx b/example-dapps/amulet-vesting/src/components/PageTitle.tsx similarity index 100% rename from dapp/frontend/src/components/PageTitle.tsx rename to example-dapps/amulet-vesting/src/components/PageTitle.tsx diff --git a/dapp/frontend/src/components/Pills.tsx b/example-dapps/amulet-vesting/src/components/Pills.tsx similarity index 100% rename from dapp/frontend/src/components/Pills.tsx rename to example-dapps/amulet-vesting/src/components/Pills.tsx diff --git a/dapp/frontend/src/components/RoleSelect.tsx b/example-dapps/amulet-vesting/src/components/RoleSelect.tsx similarity index 100% rename from dapp/frontend/src/components/RoleSelect.tsx rename to example-dapps/amulet-vesting/src/components/RoleSelect.tsx diff --git a/dapp/frontend/src/components/ScheduleBar.tsx b/example-dapps/amulet-vesting/src/components/ScheduleBar.tsx similarity index 100% rename from dapp/frontend/src/components/ScheduleBar.tsx rename to example-dapps/amulet-vesting/src/components/ScheduleBar.tsx diff --git a/dapp/frontend/src/components/ScheduleCurve.tsx b/example-dapps/amulet-vesting/src/components/ScheduleCurve.tsx similarity index 100% rename from dapp/frontend/src/components/ScheduleCurve.tsx rename to example-dapps/amulet-vesting/src/components/ScheduleCurve.tsx diff --git a/dapp/frontend/src/components/Select.tsx b/example-dapps/amulet-vesting/src/components/Select.tsx similarity index 100% rename from dapp/frontend/src/components/Select.tsx rename to example-dapps/amulet-vesting/src/components/Select.tsx diff --git a/dapp/frontend/src/components/Spinner.tsx b/example-dapps/amulet-vesting/src/components/Spinner.tsx similarity index 100% rename from dapp/frontend/src/components/Spinner.tsx rename to example-dapps/amulet-vesting/src/components/Spinner.tsx diff --git a/dapp/frontend/src/components/StatusPill.tsx b/example-dapps/amulet-vesting/src/components/StatusPill.tsx similarity index 100% rename from dapp/frontend/src/components/StatusPill.tsx rename to example-dapps/amulet-vesting/src/components/StatusPill.tsx diff --git a/dapp/frontend/src/components/StepBar.tsx b/example-dapps/amulet-vesting/src/components/StepBar.tsx similarity index 100% rename from dapp/frontend/src/components/StepBar.tsx rename to example-dapps/amulet-vesting/src/components/StepBar.tsx diff --git a/dapp/frontend/src/components/Toaster/ToastRow.tsx b/example-dapps/amulet-vesting/src/components/Toaster/ToastRow.tsx similarity index 100% rename from dapp/frontend/src/components/Toaster/ToastRow.tsx rename to example-dapps/amulet-vesting/src/components/Toaster/ToastRow.tsx diff --git a/dapp/frontend/src/components/Toaster/index.tsx b/example-dapps/amulet-vesting/src/components/Toaster/index.tsx similarity index 100% rename from dapp/frontend/src/components/Toaster/index.tsx rename to example-dapps/amulet-vesting/src/components/Toaster/index.tsx diff --git a/dapp/frontend/src/components/TopBar/AccountMenu.tsx b/example-dapps/amulet-vesting/src/components/TopBar/AccountMenu.tsx similarity index 100% rename from dapp/frontend/src/components/TopBar/AccountMenu.tsx rename to example-dapps/amulet-vesting/src/components/TopBar/AccountMenu.tsx diff --git a/dapp/frontend/src/components/TopBar/ConnectGuide.tsx b/example-dapps/amulet-vesting/src/components/TopBar/ConnectGuide.tsx similarity index 100% rename from dapp/frontend/src/components/TopBar/ConnectGuide.tsx rename to example-dapps/amulet-vesting/src/components/TopBar/ConnectGuide.tsx diff --git a/dapp/frontend/src/components/TopBar/Logo.tsx b/example-dapps/amulet-vesting/src/components/TopBar/Logo.tsx similarity index 100% rename from dapp/frontend/src/components/TopBar/Logo.tsx rename to example-dapps/amulet-vesting/src/components/TopBar/Logo.tsx diff --git a/dapp/frontend/src/components/TopBar/PartyAvatar.tsx b/example-dapps/amulet-vesting/src/components/TopBar/PartyAvatar.tsx similarity index 100% rename from dapp/frontend/src/components/TopBar/PartyAvatar.tsx rename to example-dapps/amulet-vesting/src/components/TopBar/PartyAvatar.tsx diff --git a/dapp/frontend/src/components/TopBar/ThemeToggle.tsx b/example-dapps/amulet-vesting/src/components/TopBar/ThemeToggle.tsx similarity index 100% rename from dapp/frontend/src/components/TopBar/ThemeToggle.tsx rename to example-dapps/amulet-vesting/src/components/TopBar/ThemeToggle.tsx diff --git a/dapp/frontend/src/components/TopBar/index.tsx b/example-dapps/amulet-vesting/src/components/TopBar/index.tsx similarity index 100% rename from dapp/frontend/src/components/TopBar/index.tsx rename to example-dapps/amulet-vesting/src/components/TopBar/index.tsx diff --git a/dapp/frontend/src/components/WrongNetwork.test.tsx b/example-dapps/amulet-vesting/src/components/WrongNetwork.test.tsx similarity index 100% rename from dapp/frontend/src/components/WrongNetwork.test.tsx rename to example-dapps/amulet-vesting/src/components/WrongNetwork.test.tsx diff --git a/dapp/frontend/src/components/WrongNetwork.tsx b/example-dapps/amulet-vesting/src/components/WrongNetwork.tsx similarity index 100% rename from dapp/frontend/src/components/WrongNetwork.tsx rename to example-dapps/amulet-vesting/src/components/WrongNetwork.tsx diff --git a/dapp/frontend/src/hooks/useConnectErrorToast.test.ts b/example-dapps/amulet-vesting/src/hooks/useConnectErrorToast.test.ts similarity index 100% rename from dapp/frontend/src/hooks/useConnectErrorToast.test.ts rename to example-dapps/amulet-vesting/src/hooks/useConnectErrorToast.test.ts diff --git a/dapp/frontend/src/hooks/useConnectErrorToast.ts b/example-dapps/amulet-vesting/src/hooks/useConnectErrorToast.ts similarity index 100% rename from dapp/frontend/src/hooks/useConnectErrorToast.ts rename to example-dapps/amulet-vesting/src/hooks/useConnectErrorToast.ts diff --git a/dapp/frontend/src/hooks/useCreateGrant.ts b/example-dapps/amulet-vesting/src/hooks/useCreateGrant.ts similarity index 100% rename from dapp/frontend/src/hooks/useCreateGrant.ts rename to example-dapps/amulet-vesting/src/hooks/useCreateGrant.ts diff --git a/dapp/frontend/src/hooks/useDocumentTitle.ts b/example-dapps/amulet-vesting/src/hooks/useDocumentTitle.ts similarity index 100% rename from dapp/frontend/src/hooks/useDocumentTitle.ts rename to example-dapps/amulet-vesting/src/hooks/useDocumentTitle.ts diff --git a/dapp/frontend/src/hooks/useNetworkStatus.test.ts b/example-dapps/amulet-vesting/src/hooks/useNetworkStatus.test.ts similarity index 100% rename from dapp/frontend/src/hooks/useNetworkStatus.test.ts rename to example-dapps/amulet-vesting/src/hooks/useNetworkStatus.test.ts diff --git a/dapp/frontend/src/hooks/useNetworkStatus.ts b/example-dapps/amulet-vesting/src/hooks/useNetworkStatus.ts similarity index 100% rename from dapp/frontend/src/hooks/useNetworkStatus.ts rename to example-dapps/amulet-vesting/src/hooks/useNetworkStatus.ts diff --git a/dapp/frontend/src/hooks/useRoleLens.ts b/example-dapps/amulet-vesting/src/hooks/useRoleLens.ts similarity index 100% rename from dapp/frontend/src/hooks/useRoleLens.ts rename to example-dapps/amulet-vesting/src/hooks/useRoleLens.ts diff --git a/dapp/frontend/src/icons/BootNodeMark.tsx b/example-dapps/amulet-vesting/src/icons/BootNodeMark.tsx similarity index 100% rename from dapp/frontend/src/icons/BootNodeMark.tsx rename to example-dapps/amulet-vesting/src/icons/BootNodeMark.tsx diff --git a/dapp/frontend/src/icons/DocsMark.tsx b/example-dapps/amulet-vesting/src/icons/DocsMark.tsx similarity index 100% rename from dapp/frontend/src/icons/DocsMark.tsx rename to example-dapps/amulet-vesting/src/icons/DocsMark.tsx diff --git a/dapp/frontend/src/icons/GithubMark.tsx b/example-dapps/amulet-vesting/src/icons/GithubMark.tsx similarity index 100% rename from dapp/frontend/src/icons/GithubMark.tsx rename to example-dapps/amulet-vesting/src/icons/GithubMark.tsx diff --git a/dapp/frontend/src/icons/LinkedInMark.tsx b/example-dapps/amulet-vesting/src/icons/LinkedInMark.tsx similarity index 100% rename from dapp/frontend/src/icons/LinkedInMark.tsx rename to example-dapps/amulet-vesting/src/icons/LinkedInMark.tsx diff --git a/dapp/frontend/src/icons/LogoMark.tsx b/example-dapps/amulet-vesting/src/icons/LogoMark.tsx similarity index 100% rename from dapp/frontend/src/icons/LogoMark.tsx rename to example-dapps/amulet-vesting/src/icons/LogoMark.tsx diff --git a/dapp/frontend/src/icons/Svg.tsx b/example-dapps/amulet-vesting/src/icons/Svg.tsx similarity index 100% rename from dapp/frontend/src/icons/Svg.tsx rename to example-dapps/amulet-vesting/src/icons/Svg.tsx diff --git a/dapp/frontend/src/icons/TelegramMark.tsx b/example-dapps/amulet-vesting/src/icons/TelegramMark.tsx similarity index 100% rename from dapp/frontend/src/icons/TelegramMark.tsx rename to example-dapps/amulet-vesting/src/icons/TelegramMark.tsx diff --git a/dapp/frontend/src/icons/XMark.tsx b/example-dapps/amulet-vesting/src/icons/XMark.tsx similarity index 100% rename from dapp/frontend/src/icons/XMark.tsx rename to example-dapps/amulet-vesting/src/icons/XMark.tsx diff --git a/dapp/frontend/src/icons/index.ts b/example-dapps/amulet-vesting/src/icons/index.ts similarity index 100% rename from dapp/frontend/src/icons/index.ts rename to example-dapps/amulet-vesting/src/icons/index.ts diff --git a/dapp/frontend/src/main.tsx b/example-dapps/amulet-vesting/src/main.tsx similarity index 100% rename from dapp/frontend/src/main.tsx rename to example-dapps/amulet-vesting/src/main.tsx diff --git a/dapp/frontend/src/pages/Dashboard/GrantCard/Legend.tsx b/example-dapps/amulet-vesting/src/pages/Dashboard/GrantCard/Legend.tsx similarity index 100% rename from dapp/frontend/src/pages/Dashboard/GrantCard/Legend.tsx rename to example-dapps/amulet-vesting/src/pages/Dashboard/GrantCard/Legend.tsx diff --git a/dapp/frontend/src/pages/Dashboard/GrantCard/index.tsx b/example-dapps/amulet-vesting/src/pages/Dashboard/GrantCard/index.tsx similarity index 100% rename from dapp/frontend/src/pages/Dashboard/GrantCard/index.tsx rename to example-dapps/amulet-vesting/src/pages/Dashboard/GrantCard/index.tsx diff --git a/dapp/frontend/src/pages/Dashboard/index.tsx b/example-dapps/amulet-vesting/src/pages/Dashboard/index.tsx similarity index 100% rename from dapp/frontend/src/pages/Dashboard/index.tsx rename to example-dapps/amulet-vesting/src/pages/Dashboard/index.tsx diff --git a/dapp/frontend/src/pages/GrantDetail/MilestoneTimeline.tsx b/example-dapps/amulet-vesting/src/pages/GrantDetail/MilestoneTimeline.tsx similarity index 100% rename from dapp/frontend/src/pages/GrantDetail/MilestoneTimeline.tsx rename to example-dapps/amulet-vesting/src/pages/GrantDetail/MilestoneTimeline.tsx diff --git a/dapp/frontend/src/pages/GrantDetail/index.tsx b/example-dapps/amulet-vesting/src/pages/GrantDetail/index.tsx similarity index 100% rename from dapp/frontend/src/pages/GrantDetail/index.tsx rename to example-dapps/amulet-vesting/src/pages/GrantDetail/index.tsx diff --git a/dapp/frontend/src/pages/NotFound.tsx b/example-dapps/amulet-vesting/src/pages/NotFound.tsx similarity index 100% rename from dapp/frontend/src/pages/NotFound.tsx rename to example-dapps/amulet-vesting/src/pages/NotFound.tsx diff --git a/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx b/example-dapps/amulet-vesting/src/pages/PendingGrants/PendingGrantCard.tsx similarity index 100% rename from dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx rename to example-dapps/amulet-vesting/src/pages/PendingGrants/PendingGrantCard.tsx diff --git a/dapp/frontend/src/pages/PendingGrants/index.tsx b/example-dapps/amulet-vesting/src/pages/PendingGrants/index.tsx similarity index 100% rename from dapp/frontend/src/pages/PendingGrants/index.tsx rename to example-dapps/amulet-vesting/src/pages/PendingGrants/index.tsx diff --git a/dapp/frontend/src/providers/Backend.test.tsx b/example-dapps/amulet-vesting/src/providers/Backend.test.tsx similarity index 100% rename from dapp/frontend/src/providers/Backend.test.tsx rename to example-dapps/amulet-vesting/src/providers/Backend.test.tsx diff --git a/dapp/frontend/src/providers/Backend.tsx b/example-dapps/amulet-vesting/src/providers/Backend.tsx similarity index 100% rename from dapp/frontend/src/providers/Backend.tsx rename to example-dapps/amulet-vesting/src/providers/Backend.tsx diff --git a/dapp/frontend/src/providers/Tokens.tsx b/example-dapps/amulet-vesting/src/providers/Tokens.tsx similarity index 100% rename from dapp/frontend/src/providers/Tokens.tsx rename to example-dapps/amulet-vesting/src/providers/Tokens.tsx diff --git a/dapp/frontend/src/routes.tsx b/example-dapps/amulet-vesting/src/routes.tsx similarity index 100% rename from dapp/frontend/src/routes.tsx rename to example-dapps/amulet-vesting/src/routes.tsx diff --git a/dapp/frontend/src/store/types.ts b/example-dapps/amulet-vesting/src/store/types.ts similarity index 100% rename from dapp/frontend/src/store/types.ts rename to example-dapps/amulet-vesting/src/store/types.ts diff --git a/dapp/frontend/src/store/useVestingStore.test.ts b/example-dapps/amulet-vesting/src/store/useVestingStore.test.ts similarity index 100% rename from dapp/frontend/src/store/useVestingStore.test.ts rename to example-dapps/amulet-vesting/src/store/useVestingStore.test.ts diff --git a/dapp/frontend/src/store/useVestingStore.ts b/example-dapps/amulet-vesting/src/store/useVestingStore.ts similarity index 100% rename from dapp/frontend/src/store/useVestingStore.ts rename to example-dapps/amulet-vesting/src/store/useVestingStore.ts diff --git a/dapp/frontend/src/styles/index.css b/example-dapps/amulet-vesting/src/styles/index.css similarity index 100% rename from dapp/frontend/src/styles/index.css rename to example-dapps/amulet-vesting/src/styles/index.css diff --git a/dapp/frontend/src/styles/tokens.css b/example-dapps/amulet-vesting/src/styles/tokens.css similarity index 100% rename from dapp/frontend/src/styles/tokens.css rename to example-dapps/amulet-vesting/src/styles/tokens.css diff --git a/dapp/frontend/src/utils/amount.test.ts b/example-dapps/amulet-vesting/src/utils/amount.test.ts similarity index 100% rename from dapp/frontend/src/utils/amount.test.ts rename to example-dapps/amulet-vesting/src/utils/amount.test.ts diff --git a/dapp/frontend/src/utils/amount.ts b/example-dapps/amulet-vesting/src/utils/amount.ts similarity index 100% rename from dapp/frontend/src/utils/amount.ts rename to example-dapps/amulet-vesting/src/utils/amount.ts diff --git a/dapp/frontend/src/utils/amountErrorText.ts b/example-dapps/amulet-vesting/src/utils/amountErrorText.ts similarity index 100% rename from dapp/frontend/src/utils/amountErrorText.ts rename to example-dapps/amulet-vesting/src/utils/amountErrorText.ts diff --git a/dapp/frontend/src/utils/assetList.test.ts b/example-dapps/amulet-vesting/src/utils/assetList.test.ts similarity index 100% rename from dapp/frontend/src/utils/assetList.test.ts rename to example-dapps/amulet-vesting/src/utils/assetList.test.ts diff --git a/dapp/frontend/src/utils/assetList.ts b/example-dapps/amulet-vesting/src/utils/assetList.ts similarity index 100% rename from dapp/frontend/src/utils/assetList.ts rename to example-dapps/amulet-vesting/src/utils/assetList.ts diff --git a/dapp/frontend/src/utils/clock.ts b/example-dapps/amulet-vesting/src/utils/clock.ts similarity index 100% rename from dapp/frontend/src/utils/clock.ts rename to example-dapps/amulet-vesting/src/utils/clock.ts diff --git a/dapp/frontend/src/utils/cn.ts b/example-dapps/amulet-vesting/src/utils/cn.ts similarity index 100% rename from dapp/frontend/src/utils/cn.ts rename to example-dapps/amulet-vesting/src/utils/cn.ts diff --git a/dapp/frontend/src/utils/config.ts b/example-dapps/amulet-vesting/src/utils/config.ts similarity index 100% rename from dapp/frontend/src/utils/config.ts rename to example-dapps/amulet-vesting/src/utils/config.ts diff --git a/dapp/frontend/src/utils/env.test.ts b/example-dapps/amulet-vesting/src/utils/env.test.ts similarity index 100% rename from dapp/frontend/src/utils/env.test.ts rename to example-dapps/amulet-vesting/src/utils/env.test.ts diff --git a/dapp/frontend/src/utils/env.ts b/example-dapps/amulet-vesting/src/utils/env.ts similarity index 100% rename from dapp/frontend/src/utils/env.ts rename to example-dapps/amulet-vesting/src/utils/env.ts diff --git a/dapp/frontend/src/utils/errorText.ts b/example-dapps/amulet-vesting/src/utils/errorText.ts similarity index 100% rename from dapp/frontend/src/utils/errorText.ts rename to example-dapps/amulet-vesting/src/utils/errorText.ts diff --git a/dapp/frontend/src/utils/format.test.ts b/example-dapps/amulet-vesting/src/utils/format.test.ts similarity index 100% rename from dapp/frontend/src/utils/format.test.ts rename to example-dapps/amulet-vesting/src/utils/format.test.ts diff --git a/dapp/frontend/src/utils/format.ts b/example-dapps/amulet-vesting/src/utils/format.ts similarity index 100% rename from dapp/frontend/src/utils/format.ts rename to example-dapps/amulet-vesting/src/utils/format.ts diff --git a/dapp/frontend/src/utils/network.test.ts b/example-dapps/amulet-vesting/src/utils/network.test.ts similarity index 100% rename from dapp/frontend/src/utils/network.test.ts rename to example-dapps/amulet-vesting/src/utils/network.test.ts diff --git a/dapp/frontend/src/utils/network.ts b/example-dapps/amulet-vesting/src/utils/network.ts similarity index 100% rename from dapp/frontend/src/utils/network.ts rename to example-dapps/amulet-vesting/src/utils/network.ts diff --git a/dapp/frontend/src/utils/popover.ts b/example-dapps/amulet-vesting/src/utils/popover.ts similarity index 100% rename from dapp/frontend/src/utils/popover.ts rename to example-dapps/amulet-vesting/src/utils/popover.ts diff --git a/dapp/frontend/src/utils/randomId.ts b/example-dapps/amulet-vesting/src/utils/randomId.ts similarity index 100% rename from dapp/frontend/src/utils/randomId.ts rename to example-dapps/amulet-vesting/src/utils/randomId.ts diff --git a/dapp/frontend/src/utils/schedule.test.ts b/example-dapps/amulet-vesting/src/utils/schedule.test.ts similarity index 100% rename from dapp/frontend/src/utils/schedule.test.ts rename to example-dapps/amulet-vesting/src/utils/schedule.test.ts diff --git a/dapp/frontend/src/utils/schedule.ts b/example-dapps/amulet-vesting/src/utils/schedule.ts similarity index 100% rename from dapp/frontend/src/utils/schedule.ts rename to example-dapps/amulet-vesting/src/utils/schedule.ts diff --git a/dapp/frontend/src/utils/standIn.ts b/example-dapps/amulet-vesting/src/utils/standIn.ts similarity index 100% rename from dapp/frontend/src/utils/standIn.ts rename to example-dapps/amulet-vesting/src/utils/standIn.ts diff --git a/dapp/frontend/src/utils/toast.test.ts b/example-dapps/amulet-vesting/src/utils/toast.test.ts similarity index 100% rename from dapp/frontend/src/utils/toast.test.ts rename to example-dapps/amulet-vesting/src/utils/toast.test.ts diff --git a/dapp/frontend/src/utils/toast.ts b/example-dapps/amulet-vesting/src/utils/toast.ts similarity index 100% rename from dapp/frontend/src/utils/toast.ts rename to example-dapps/amulet-vesting/src/utils/toast.ts diff --git a/dapp/frontend/src/utils/tokens.tsx b/example-dapps/amulet-vesting/src/utils/tokens.tsx similarity index 100% rename from dapp/frontend/src/utils/tokens.tsx rename to example-dapps/amulet-vesting/src/utils/tokens.tsx diff --git a/dapp/frontend/src/vite-env.d.ts b/example-dapps/amulet-vesting/src/vite-env.d.ts similarity index 100% rename from dapp/frontend/src/vite-env.d.ts rename to example-dapps/amulet-vesting/src/vite-env.d.ts diff --git a/dapp/frontend/tsconfig.app.json b/example-dapps/amulet-vesting/tsconfig.app.json similarity index 100% rename from dapp/frontend/tsconfig.app.json rename to example-dapps/amulet-vesting/tsconfig.app.json diff --git a/example-dapps/amulet-vesting/tsconfig.json b/example-dapps/amulet-vesting/tsconfig.json new file mode 100644 index 00000000..d32ff682 --- /dev/null +++ b/example-dapps/amulet-vesting/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/dapp/frontend/tsconfig.node.json b/example-dapps/amulet-vesting/tsconfig.node.json similarity index 100% rename from dapp/frontend/tsconfig.node.json rename to example-dapps/amulet-vesting/tsconfig.node.json diff --git a/dapp/frontend/vercel.json b/example-dapps/amulet-vesting/vercel.json similarity index 63% rename from dapp/frontend/vercel.json rename to example-dapps/amulet-vesting/vercel.json index d9937dd1..e05157e2 100644 --- a/dapp/frontend/vercel.json +++ b/example-dapps/amulet-vesting/vercel.json @@ -2,7 +2,7 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "vite", "installCommand": "pnpm install", - "buildCommand": "cd ../.. && pnpm -r --filter '!./dapp/daml' run --if-present build", + "buildCommand": "cd ../.. && pnpm -r --filter '@bootnodedev/canton-example-amulet-vesting...' run --if-present build", "outputDirectory": "dist", "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] } diff --git a/dapp/frontend/vite.config.ts b/example-dapps/amulet-vesting/vite.config.ts similarity index 92% rename from dapp/frontend/vite.config.ts rename to example-dapps/amulet-vesting/vite.config.ts index 15087997..3bf54b28 100644 --- a/dapp/frontend/vite.config.ts +++ b/example-dapps/amulet-vesting/vite.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from 'vitest/config' // biome-ignore lint/style/noRestrictedImports: see above; both halves of this specifier are forced. import { parseEnv } from './src/utils/env.ts' -// The empty prefix loads every key in the root `.env`, so only what `parseEnv` returns may be +// The empty prefix loads every key in `.env`, so only what `parseEnv` returns may be // defined back, never the loaded object. // LocalNet serves the token registry behind the validator's authenticated prefix, so the browser // cannot read it: the bearer stays here, in the dev server, and the page asks its own origin. @@ -74,17 +74,13 @@ const localnetAssets = (values: Record<string, string>): Plugin => ({ }) export default defineConfig(({ mode }) => { - const envDir = fileURLToPath(new URL('../..', import.meta.url)) - const loaded = loadEnv(mode, envDir, '') + const loaded = loadEnv(mode, fileURLToPath(new URL('.', import.meta.url)), '') const env = parseEnv(loaded) return { define: Object.fromEntries( Object.entries(env).map(([key, value]) => [`import.meta.env.${key}`, JSON.stringify(value)]), ), - // Without this a leftover `dapp/frontend/.env.local` is still loaded, silently losing to the - // root for exactly the keys defined above. - envDir, plugins: [react(), tailwindcss(), localnetAssets(loaded)], resolve: { alias: { diff --git a/kit/manifests.mjs b/kit/manifests.mjs deleted file mode 100644 index b5e0b00c..00000000 --- a/kit/manifests.mjs +++ /dev/null @@ -1,41 +0,0 @@ -// The release script and the version gate need the same view of the workspace: every manifest, and -// which of them are the published libraries. Deriving both here is what keeps a new library or a -// new consumer of one from needing an edit in either. - -import fs from 'node:fs' -import path from 'node:path' -import { repoRoot } from '../scripts/lib/gate.mjs' - -export const ROOT_MANIFEST = 'package.json' - -// Two levels, which is every entry in pnpm-workspace.yaml's `packages` (canton-*, dapp/*); reading -// that file needs a YAML parser no script here has, and a `**` glob would sweep in the package.json -// files under `.canton-localnet/`. A workspace package deeper than two levels is invisible here. -const PATTERNS = [ROOT_MANIFEST, '*/package.json', '*/*/package.json'] - -export const DEPENDENCY_FIELDS = [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'optionalDependencies', -] - -/** Every workspace manifest, parsed, keyed by its repo-relative path. */ -export const readManifests = () => - Object.fromEntries( - fs - .globSync(PATTERNS, { cwd: repoRoot, exclude: (entry) => entry.includes('node_modules') }) - .sort() - .map((file) => [file, JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8'))]), - ) - -/** - * The published libraries, by package name. A workspace manifest is one unless it is private, - * which is what leaves the root, `dapp/frontend` and `dapp/daml` out of the lockstep. - */ -export const libraryNames = (manifests) => - new Set( - Object.values(manifests) - .filter((manifest) => manifest.private !== true && typeof manifest.name === 'string') - .map((manifest) => manifest.name), - ) diff --git a/knip.json b/knip.json index 82a10280..75cfb97e 100644 --- a/knip.json +++ b/knip.json @@ -1,18 +1,29 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", "ignoreExportsUsedInFile": true, - "ignoreDependencies": ["postcss"], "ignoreBinaries": ["dpm"], - "ignoreWorkspaces": ["dapp/daml"], "workspaces": { ".": { - "entry": ["scripts/**/*.mjs!", "kit/**/*.mjs!", ".lintstagedrc.format.mjs"] + "entry": ["scripts/*.mjs!", ".lintstagedrc.format.mjs"] }, "canton-connect": { "entry": ["src/**/*.test.{ts,tsx}"] }, "canton-dappbooster": { "entry": ["src/**/*.test.{ts,tsx}", "scripts/*.mjs!"] + }, + "canton-create": { + "entry": ["src/**/*.test.ts", "scripts/*.ts"], + "ignore": ["src/testing/fixtures/**"] + }, + "canton-create/scaffold/starter": { + "ignoreDependencies": ["@bootnodedev/canton-theme"] + }, + "canton-create/scaffold/daml-tooling": { + "entry": ["scripts/*.mjs!"] + }, + "canton-create/scaffold/localnet": { + "entry": ["scripts/*.mjs!"] } } } diff --git a/package.json b/package.json index 6e8adc66..9fd431a3 100644 --- a/package.json +++ b/package.json @@ -8,30 +8,31 @@ "node": ">=24.15.0" }, "scripts": { - "app:dev": "pnpm -C dapp/frontend run dev", - "bootstrap": "node scripts/bootstrap-vesting.mjs", - "build-dar": "pnpm -C dapp/daml run build", + "app:dev": "pnpm -C example-dapps/amulet-vesting run dev", + "bootstrap": "pnpm -C example-dapps/amulet-vesting run bootstrap", + "build-dar": "CANTON_APP_DIR=$PWD/example-dapps/amulet-vesting pnpm -C canton-create/scaffold/daml-tooling run build-dar", "build": "pnpm -r run --if-present build", - "check:anatomy": "node kit/check-anatomy.mjs", - "check:versions": "node kit/check-versions.mjs", - "deploy-dar": "bash scripts/deploy-dar.sh", - "docs:build": "typedoc --options kit/typedoc.json", - "docs:check": "typedoc --options kit/typedoc.json --emit none && node kit/docs-check.mjs", + "check:anatomy": "node scripts/check-anatomy.mjs", + "check:versions": "node scripts/check-versions.mjs", + "deploy-dar": "CANTON_APP_DIR=$PWD/example-dapps/amulet-vesting bash canton-create/scaffold/daml-tooling/scripts/deploy-dar.sh", + "docs:build": "typedoc --options scripts/typedoc.json", + "docs:check": "typedoc --options scripts/typedoc.json --emit none && node scripts/docs-check.mjs", "knip": "knip", "lint:fix": "biome check --write", "lint": "biome check --error-on-warnings", - "mint-token": "node scripts/mint-token.mjs ledger-api-user", + "mint-token": "CANTON_APP_DIR=$PWD/example-dapps/amulet-vesting pnpm -C canton-create/scaffold/localnet run mint-token", "prepare": "husky", - "release": "pnpm -r --filter './canton-*' publish --no-git-checks", - "release:dry": "pnpm -r --filter './canton-*' publish --dry-run --no-git-checks", - "release:version": "node kit/release-version.mjs", - "test": "pnpm -r run --if-present test && node --test \"scripts/**/*.test.mjs\" \"kit/**/*.test.mjs\"", + "release": "pnpm -r publish --no-git-checks", + "release:dry": "pnpm -r publish --dry-run --no-git-checks", + "release:version": "node scripts/release-version.mjs", + "stack": "CANTON_APP_DIR=$PWD/example-dapps/amulet-vesting bash canton-create/scaffold/localnet/scripts/dev-stack.sh", + "starter:dev": "pnpm -C canton-create/scaffold/starter run dev", + "test": "pnpm -r run --if-present test && node --test \"canton-create/scaffold/*/scripts/*.test.mjs\" \"scripts/*.test.mjs\"", "typecheck": "pnpm -r run --if-present typecheck", - "wallet-gateway": "mkdir -p .wallet-gateway && wallet-gateway -c ./wallet-gateway.config.json" + "wallet-gateway": "mkdir -p .wallet-gateway && wallet-gateway -c canton-create/scaffold/localnet/wallet-gateway.config.json" }, "devDependencies": { "@biomejs/biome": "2.5.15", - "@bootnodedev/canton-barebones": "0.6.0", "@canton-network/wallet-gateway-remote": "1.11.3", "@commitlint/cli": "^21.0.1", "@commitlint/config-conventional": "^21.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d885863..f77370f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,9 +169,6 @@ importers: '@biomejs/biome': specifier: 2.5.15 version: 2.5.15 - '@bootnodedev/canton-barebones': - specifier: 0.6.0 - version: 0.6.0 '@canton-network/wallet-gateway-remote': specifier: 1.11.3 version: 1.11.3(@types/node@26.6.4)(debug@4.4.3(supports-color@8.1.1))(jiti@2.7.0)(lightningcss@1.33.0)(supports-color@8.1.1)(yaml@2.9.1) @@ -270,6 +267,96 @@ importers: specifier: ^5.0.0 version: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2(@noble/hashes@1.8.0))(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + canton-create: + devDependencies: + '@inkjs/ui': + specifier: ^2.0.0 + version: 2.0.0(ink@8.0.0(@types/react@19.3.0)(react@19.3.0)) + '@types/node': + specifier: ^24.0.0 + version: 24.19.1 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + ink: + specifier: ^8.0.0 + version: 8.0.0(@types/react@19.3.0)(react@19.3.0) + ink-gradient: + specifier: ^4.0.1 + version: 4.0.1(ink@8.0.0(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + ink-link: + specifier: ^5.0.0 + version: 5.0.0(ink@8.0.0(@types/react@19.3.0)(react@19.3.0)) + ink-testing-library: + specifier: ^4.0.0 + version: 4.0.0(@types/react@19.3.0) + react: + specifier: ^19.3.0 + version: 19.3.0 + tsdown: + specifier: ^0.22.14 + version: 0.22.14(@arethetypeswrong/core@0.18.5)(oxc-resolver@11.24.2)(publint@0.3.25)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.11(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@1.8.0))(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + + canton-create/scaffold/daml-tooling: {} + + canton-create/scaffold/localnet: + devDependencies: + '@bootnodedev/canton-barebones': + specifier: 0.6.0 + version: 0.6.0 + '@canton-network/wallet-gateway-remote': + specifier: 1.11.3 + version: 1.11.3(@types/node@26.6.4)(debug@4.4.3(supports-color@8.1.1))(jiti@2.7.0)(lightningcss@1.33.0)(supports-color@8.1.1)(yaml@2.9.1) + + canton-create/scaffold/starter: + dependencies: + '@bootnodedev/canton-connect': + specifier: ^0.5.6 + version: link:../../../canton-connect + '@bootnodedev/canton-dappbooster': + specifier: ^0.5.6 + version: link:../../../canton-dappbooster + '@bootnodedev/canton-theme': + specifier: ^0.5.6 + version: link:../../../canton-theme + '@canton-network/core-types': + specifier: ^1.8.0 + version: 1.10.0 + '@canton-network/dapp-sdk': + specifier: 1.5.1 + version: 1.5.1(@walletconnect/sign-client@2.25.0(typescript@5.9.3)(zod@4.6.5))(@walletconnect/types@2.25.0)(supports-color@8.1.1) + '@walletconnect/sign-client': + specifier: ^2.23.9 + version: 2.25.0(typescript@5.9.3)(zod@4.6.5) + react: + specifier: ^19.1.0 + version: 19.3.0 + react-dom: + specifier: ^19.1.0 + version: 19.3.0(react@19.3.0) + devDependencies: + '@types/react': + specifier: ^19.1.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.1.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: ^6.0.0 + version: 6.1.1(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.0.0 + version: 8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) + canton-dappbooster: dependencies: '@zag-js/dialog': @@ -330,9 +417,7 @@ importers: canton-theme: {} - dapp/daml: {} - - dapp/frontend: + example-dapps/amulet-vesting: dependencies: '@ark-ui/react': specifier: ^5.39.1 @@ -413,6 +498,10 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@alcalzone/ansi-tokenize@0.3.1': + resolution: {integrity: sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg==} + engines: {node: '>=18'} + '@andrewbranch/untar.js@1.0.4': resolution: {integrity: sha512-pVXSwPsLuw8IGLo2Di0EaOfsk+ntVvpkk942J/sHYIkwvtKUakEcPh7HBgZ6tuimgzKSEHgCvO4XgQ05DEbwDw==} @@ -1037,6 +1126,12 @@ packages: '@gerrit0/mini-shiki@3.23.0': resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + '@inkjs/ui@2.0.0': + resolution: {integrity: sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==} + engines: {node: '>=18'} + peerDependencies: + ink: '>=5' + '@internationalized/date@3.12.4': resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} @@ -1723,6 +1818,9 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -1873,6 +1971,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/gradient-string@1.1.6': + resolution: {integrity: sha512-LkaYxluY4G5wR1M4AKQUal2q61Di1yVVCw42ImFTuaIoQVgmV0WP1xUaLB8zwb47mp82vWTpePI9JmrjEnJ7nQ==} + '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} @@ -1882,6 +1983,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + '@types/node@25.9.9': resolution: {integrity: sha512-b4e2xxj/yMeT2hNlD6x7zNFiQK2Dlmkb9CQ6v3/1G2RezKN4YS2lcEv8lzGm1mhs4f2lM4yNAMI/zUDBZBBt7w==} @@ -1899,6 +2003,9 @@ packages: '@types/superagent@4.1.15': resolution: {integrity: sha512-mu/N4uvfDN2zVQQ5AYJI/g4qxn2bHB6521t1UuH09ShNWjebTqN0ZFuYK9uYjcgmI0dTQEs+Owi1EO6U0OkOZQ==} + '@types/tinycolor2@1.4.6': + resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1930,6 +2037,9 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/istanbul-lib-coverage@1.0.2': resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} engines: {node: '>=22'} @@ -1938,6 +2048,17 @@ packages: resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} engines: {node: '>=22'} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@5.0.3': resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} peerDependencies: @@ -1949,9 +2070,24 @@ packages: vite: optional: true + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/spy@5.0.3': resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@walletconnect/core@2.25.0': resolution: {integrity: sha512-ZcZauyQtSBhwwIbXIVTojcg/iWf1QLnf06zSJNGqHAJHHvWedg2Hpj+E2hinKRkPEqeaqf/ltkwOBOpF1Lcuzg==} engines: {node: '>=18.20.8'} @@ -2033,24 +2169,44 @@ packages: os: [android] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} + cpu: [arm64] + os: [android] + '@yuku-codegen/binding-darwin-arm64@0.10.2': resolution: {integrity: sha512-3H7eNPIHJndUJXZ4QUGBPq1zC6RGcUZfm7bymJes+/N7wTVWUn1bxZ9JcozYHpkWQNm9f23G8YWbaHD9aYH72A==} cpu: [arm64] os: [darwin] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-/u+REDMI4a0/lsJXTM4c53/w31OGZLOReZIyg62uhgLs0kc8NHsj/nOcxTdlQjq5gi0zhdkccD9LaLTXcdzPvw==} + cpu: [arm64] + os: [darwin] + '@yuku-codegen/binding-darwin-x64@0.10.2': resolution: {integrity: sha512-AJOUpR2s3LF9AWiiub/Uyc/UoXNTWcq8hK3cVI6fUSXtwh34dG21J6hRuNlO6JVJFTo4o3ScVvZOrh/YFfUEAA==} cpu: [x64] os: [darwin] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-sMMzFOwCo4WXR+/6zIBThOocSC50iIIZZdfiIDbaLvj0Ax/rWt/iavyfEAqajyvzydLyCqR/ZItdLWSRlu1umw==} + cpu: [x64] + os: [darwin] + '@yuku-codegen/binding-freebsd-x64@0.10.2': resolution: {integrity: sha512-ms7DcZu87u5CiK/wMwvpKAvAmtaqKjCQIXNweMLypG6qbuiaOMQf1jpbB5+j46xBcgCovo8TQMcv0bCQLKIL9w==} cpu: [x64] os: [freebsd] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-MpdpKXix9P+Y1rKgjvcNeNtGjXeL1CmttNhYINrWls8kRpm4xM/oBGTmn6w7to8lAlwj5jm8q03dQPl5mRv4Qw==} + cpu: [x64] + os: [freebsd] + '@yuku-codegen/binding-linux-arm-gnu@0.10.2': resolution: {integrity: sha512-xJDpYAsKV5+eaiBhTYl05fvT6sst4PsCeuIFMRu9b0/WCSrNQPfCDtAQ5/MS6xNpsdujdZEPR+gmfWk3ZG5i3A==} cpu: [arm] @@ -2058,6 +2214,12 @@ packages: libc: [glibc] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-rr1srFLlPAmC1vtxfc9C1YLDe3iH09YjfSeeIidBqKhzx1MATjOAq4mjlRUOnhr/L27MotWIYOFKwVsd5JZFOg==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@yuku-codegen/binding-linux-arm-musl@0.10.2': resolution: {integrity: sha512-qfTkgd7AEx61l4K9VtXWCGTxc/fmXJ4ZheDz3i+/AAJUtg4sa0bPrc0VBxPggB/rayR8Z3+JfrBItuyanBJJ9Q==} cpu: [arm] @@ -2065,6 +2227,12 @@ packages: libc: [musl] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-eAufXh8qBRpiSO6ueaMDL+yyoXIGLhpUce72YbcACtZU2qhExwBIJyEtQf5kH2Ki0X2aqkSjSfog4OPtKXan3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + '@yuku-codegen/binding-linux-arm64-gnu@0.10.2': resolution: {integrity: sha512-4e6Mifm/4UdjtU3D8mATIrvgP+xEiH8xtQOjd0zpd72XKWT7ug3sdyhq2utkkZFP6BvYp7SgZrI+aR4VeIOHdw==} cpu: [arm64] @@ -2072,6 +2240,12 @@ packages: libc: [glibc] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-gw4w6wPoHObBrdIC4duVWLmJOvpdE25j5D7yrM5mACNlK4klRz/lv8hK+ssQk9EJHBgZjSaqZIJVFgqNYbfv7A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@yuku-codegen/binding-linux-arm64-musl@0.10.2': resolution: {integrity: sha512-RdsJrUfDYFVV3JOmWFUWwSu31fa1APn12xDeIKxgl/YWxrabwtxsDBNjF2561c7tmcbiewdCUvngqRs8AyGVLA==} cpu: [arm64] @@ -2079,6 +2253,12 @@ packages: libc: [musl] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-L68N6Y4XkqcIaKo3Ra88JEvBEH4AHff44A4INcrxeVWZ8CZtu2tCpfVxe3hR8qQQMcvBSQlDn24KpkCKEhVvfA==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@yuku-codegen/binding-linux-x64-gnu@0.10.2': resolution: {integrity: sha512-mVzWimEPPreaPyXIUvxsHoJzvO7ckZ5j0LgQFHz04zQIRwt9A9T2hA7K5/jYjJKkMxWG/U2VjhGNwVhtyU+uqg==} cpu: [x64] @@ -2086,6 +2266,12 @@ packages: libc: [glibc] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-dzyAbltJmf3Cqlb8HcFuYIf5Yn0fl1vTr3XJ9HiVNNvOlhqPSArOqtw9vI1p6/VTXTjrMLXlU+s+/kNHiIy/Cw==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@yuku-codegen/binding-linux-x64-musl@0.10.2': resolution: {integrity: sha512-Y77fyISurmr2mvO1yeq3u+x/WJbACgPR4w+lzEVx30ViCxZzIGrZPRN1yEdZ9xUNPNsIO5thBHIdRNG+UGUG+Q==} cpu: [x64] @@ -2093,42 +2279,78 @@ packages: libc: [musl] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-Otw4MH3404q0Bbvl+YTdW9aoUV5vXmUw8260bWvt1XlaoIX/ceSgI4ygheRDMfBPoHt6FDayQaO9OLVUZAgkFA==} + cpu: [x64] + os: [linux] + libc: [musl] + '@yuku-codegen/binding-win32-arm64@0.10.2': resolution: {integrity: sha512-1+tGLyG0u5YYy0lev4ck7/0sd8xJ9SZde0dLut7qLDPZV5WVTV8x6OxNXUne/PGuHZyO1vK+txPQzzHOyXHGSw==} cpu: [arm64] os: [win32] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-qo/jyrzryiBuKEsFiuWaBCBe3tRMynQ0qFWFgOEjcCMQeZfBm+wKiVEUEFXXLc7bh8YezguAWp0Mtnhq4ARNyA==} + cpu: [arm64] + os: [win32] + '@yuku-codegen/binding-win32-x64@0.10.2': resolution: {integrity: sha512-8a2SExRohRbwC0MY4VpOF0/RSckrJ2ASZqAor5/RYSJJaeadR93n10gZzcKT6pY9ukiSOIZCRKVRD2tH73T0qA==} cpu: [x64] os: [win32] deprecated: yuku-codegen is pure JavaScript since 0.14 + '@yuku-codegen/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-D5lDsVDx6m00E6bWySlWdH72Ca4TPSaphDqB6QjU6MpuNLIJqoGoatYyq2rOmBE8Zv/kunot/o58KGL03P3eiA==} + cpu: [x64] + os: [win32] + '@yuku-parser/binding-android-arm64@0.10.2': resolution: {integrity: sha512-2VPBU9fRGRAQ2xPAvghnec3oou5Nrxm2Bezhf+13UMspAxQSkF/32r+ySKXSwIPA5/RivumDJDxAxc301Sgicw==} cpu: [arm64] os: [android] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ==} + cpu: [arm64] + os: [android] + '@yuku-parser/binding-darwin-arm64@0.10.2': resolution: {integrity: sha512-LD+PMZE51tCYTOss5HBkm3/AE39MvcMDBfWJx7A4yDcjfNbAQDnHZKtzSOuqrswx+TQHY0ws5xn6+fWOwtmfBA==} cpu: [arm64] os: [darwin] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ==} + cpu: [arm64] + os: [darwin] + '@yuku-parser/binding-darwin-x64@0.10.2': resolution: {integrity: sha512-mmZ8cND+AoIIMRERyMinlg5ApHxP23B9jH2B5wT7T+dliPa9rubLxneB/SUjFwyUjGaFnB5G7t4YvpfbO5zbkQ==} cpu: [x64] os: [darwin] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA==} + cpu: [x64] + os: [darwin] + '@yuku-parser/binding-freebsd-x64@0.10.2': resolution: {integrity: sha512-gVIjaaIddbRfAhHlC8N809wQWml7mxfSVnzaLtzGXObTeFTPAg/YVnQXt1UOhPM1ah5eG/8Y0RUlNpB4GVe7eQ==} cpu: [x64] os: [freebsd] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA==} + cpu: [x64] + os: [freebsd] + '@yuku-parser/binding-linux-arm-gnu@0.10.2': resolution: {integrity: sha512-q/XPPQQAPdlw05aPj30ygBhekmQryGOwxVraBgApjKK8yY1kNQgqq6XCYLF1WHSee+lhxclrTO7W0/bt7dR1GA==} cpu: [arm] @@ -2136,6 +2358,12 @@ packages: libc: [glibc] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-arm-musl@0.10.2': resolution: {integrity: sha512-A+Cb0I1hFF4wilTQpWs79k1aNnj4B2tkrHx3zsuUNF9BtVY2zPZ4yeQEM/zjXrS/qJlNMZVK9NrWJjwdpnTrfg==} cpu: [arm] @@ -2143,6 +2371,12 @@ packages: libc: [musl] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ==} + cpu: [arm] + os: [linux] + libc: [musl] + '@yuku-parser/binding-linux-arm64-gnu@0.10.2': resolution: {integrity: sha512-aGNSzqIqqphFwAdIFwVvKIyXD1Iy3CxrEJVIZAT87Ecyi4vCmmDD2v2l9h+Gd3/wSy3JZlOI792LJbKAjyy9rQ==} cpu: [arm64] @@ -2150,6 +2384,12 @@ packages: libc: [glibc] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-arm64-musl@0.10.2': resolution: {integrity: sha512-Ddl1sF0rtuCXyHGSOV6dcmdx+ETv1iD+IVFqQuOjzkJZWclxJxdBWX6ZJMRtTiGqGUTbqLEKiTXxpR/Bvqk+2A==} cpu: [arm64] @@ -2157,6 +2397,12 @@ packages: libc: [musl] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@yuku-parser/binding-linux-x64-gnu@0.10.2': resolution: {integrity: sha512-/nlcpR6IF5U+0m5L9wvAIXeQa2DT+BXuvqKDsTcOTlcc0B4dHNyqE5hTXsS4hAyimpy2ZK8A1zMNF5hTrjg2cg==} cpu: [x64] @@ -2164,6 +2410,12 @@ packages: libc: [glibc] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-x64-musl@0.10.2': resolution: {integrity: sha512-GX80dxTQD/M/OryyuxJcFzFENzry3cDFPvCFTyWogNWQH3fSHfMrNfpwpl1YzMos1eV9NygK5GSDG7VArQlb4w==} cpu: [x64] @@ -2171,21 +2423,40 @@ packages: libc: [musl] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ==} + cpu: [x64] + os: [linux] + libc: [musl] + '@yuku-parser/binding-win32-arm64@0.10.2': resolution: {integrity: sha512-agePQBV4VHewiGU0ACSjscZ/hJd283f9JfAF19Gf1rI5+wy5tTgx4GS36i0b3xim15AQ4RCpDRosEvhLZ4zAOw==} cpu: [arm64] os: [win32] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw==} + cpu: [arm64] + os: [win32] + '@yuku-parser/binding-win32-x64@0.10.2': resolution: {integrity: sha512-s8//CMpgL5+y1lvDCdyh1rwGI5+ytDJywFJRe1vnhI7n0j+caxshNucurikYLLVeQ2SYFex6aPrzgQxkabBQRA==} cpu: [x64] os: [win32] deprecated: yuku-parser runs on yuku-core since 0.14 + '@yuku-parser/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w==} + cpu: [x64] + os: [win32] + '@yuku-toolchain/types@0.10.2': resolution: {integrity: sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q==} + '@yuku-toolchain/types@0.8.7': + resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} + '@zag-js/accordion@1.43.3': resolution: {integrity: sha512-J6rGbMPXhYDa2dLgp66DPpwC3OcNAJH71kuGakz7SfCvQWiR9lIyB26G/QdSjJjf0rVARM8rN90e5PxZjYdWjA==} @@ -2490,6 +2761,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2510,6 +2785,14 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansi-styles@7.0.0: + resolution: {integrity: sha512-kKvt3m4uwzqL0wlkPd09CmljPJGOZZ4D0fP65sqFSvPkMRKhNi+74MgIJ5QxE6SxqB4t4KyUFGg8+n5zjo6hew==} + engines: {node: '>=22'} + + ansis@4.4.0: + resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==} + engines: {node: '>=14'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2551,6 +2834,10 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + axios@1.20.0: resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} @@ -2662,6 +2949,14 @@ packages: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chalk@6.0.1: + resolution: {integrity: sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==} + engines: {node: '>=22'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -2672,6 +2967,22 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -2679,6 +2990,10 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2726,6 +3041,13 @@ packages: engines: {node: '>=22'} hasBin: true + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-es@1.2.3: resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} @@ -2825,6 +3147,10 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -2926,6 +3252,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2966,6 +3296,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3031,6 +3365,10 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -3113,6 +3451,10 @@ packages: get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + get-tsconfig@5.0.0-beta.6: resolution: {integrity: sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==} engines: {node: '>=20.20.0'} @@ -3131,6 +3473,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gradient-string@3.0.0: + resolution: {integrity: sha512-frdKI4Qi8Ihp4C6wZNB565de/THpIaw3DjP5ku87M+N9rNSGmPTjfkq61SdRXB7eCaL8O1hkKDvf6CDMtOzIAg==} + engines: {node: '>=14'} + h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} @@ -3138,6 +3484,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -3205,6 +3555,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3215,6 +3569,41 @@ packages: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} + ink-gradient@4.0.1: + resolution: {integrity: sha512-0ckdiM84zkfCdnTtcnq4BS3egIhUPPDoCqSx/7NUFsAVooBbdRuGnnWpk0fuaOTqU6rlZRh9F4LN1UI8fxd81Q==} + engines: {node: '>=20'} + peerDependencies: + ink: '>=6' + react: '>=19.2.0' + + ink-link@5.0.0: + resolution: {integrity: sha512-TFDXc/0mwUW7LMjsr0/LeLxPVV5BnHDuDQff9RCgP4rb3R+V/4dIwGBZbCevcJZtQnVcW+Iz1LUrUbpq+UDwYA==} + engines: {node: '>=18'} + peerDependencies: + ink: '>=6' + + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + + ink@8.0.0: + resolution: {integrity: sha512-M2aFwZOqmkFxCwedy6xrP4E+DGoK57iahAFnfTqNyOyJ54O7DZzu027z/NI8EGy44Sw9O9BcbTF4QEUS2GHAtg==} + engines: {node: '>=22'} + peerDependencies: + '@types/react': '>=19.3.0' + react: '>=19.3.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + ip-address@10.7.3: resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} engines: {node: '>= 12'} @@ -3237,6 +3626,15 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -3247,6 +3645,10 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -3614,6 +4016,10 @@ packages: engines: {node: '>=4'} hasBin: true + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -3727,6 +4133,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + openapi-fetch@0.17.0: resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} @@ -3787,6 +4197,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3797,6 +4211,9 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-freehand@1.2.3: resolution: {integrity: sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==} @@ -3986,6 +4403,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-reconciler@0.34.0: + resolution: {integrity: sha512-eVibvB1fc9j5Qf1KRkiM3GzRPBdjg2P6G6QV2f3P6Eb3OGMxRBhdg2rrUwjbV5JrQaJj8nOa8uajmmJA5tJWjQ==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.3.0 + react-router-dom@7.18.4: resolution: {integrity: sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==} engines: {node: '>=20.0.0'} @@ -4053,6 +4476,29 @@ packages: engines: {node: '>= 0.4'} hasBin: true + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + rolldown-plugin-dts@0.27.14: + resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + rolldown-plugin-dts@0.28.6: resolution: {integrity: sha512-qKrFtBfRfR2hP233m7Ic9zf3wv6MSYdZghWKMdEO6dRYZGlNfINJAa1P5ZVvyHh3mrcd7IJQvIY8L9vnm+v5rQ==} engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} @@ -4168,12 +4614,22 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + slice-ansi@9.0.1: + resolution: {integrity: sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==} + engines: {node: '>=22'} + slow-redact@0.3.2: resolution: {integrity: sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==} @@ -4195,6 +4651,13 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -4241,10 +4704,18 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + supports-hyperlinks@4.6.0: + resolution: {integrity: sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ==} + engines: {node: '>=20'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -4267,6 +4738,14 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + terminal-link@5.0.0: + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} + engines: {node: '>=20'} + + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + thread-stream@3.2.0: resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} @@ -4274,10 +4753,16 @@ packages: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinybench@6.2.0: resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} engines: {node: '>=20.0.0'} + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyexec@1.3.1: resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} @@ -4286,6 +4771,9 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinygradient@1.1.5: + resolution: {integrity: sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==} + tinyrainbow@3.2.0: resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} engines: {node: '>=14.0.0'} @@ -4316,6 +4804,40 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + tsdown@0.22.14: + resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.14 + '@tsdown/exe': 0.22.14 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + tsdown@0.23.0: resolution: {integrity: sha512-BaT+ep1xnj5hdyICLd5r5SYYE+TXnI1ATePLykv9vcKpHpFTWUWRH+x1AF/E2qcu3YB6+vI8IdyxIIIffEqw5Q==} engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} @@ -4554,6 +5076,10 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + verkit@0.4.1: resolution: {integrity: sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==} engines: {node: '>=18.12.0'} @@ -4641,7 +5167,48 @@ packages: optional: true tsx: optional: true - yaml: + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: optional: true vitest@5.0.3: @@ -4714,11 +5281,24 @@ packages: which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + why-is-node-running@3.2.1: resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} engines: {node: '>=20.11'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + + wrap-ansi@10.0.2: + resolution: {integrity: sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==} + engines: {node: '>=20'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -4787,15 +5367,27 @@ packages: resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + yuku-ast@0.10.2: resolution: {integrity: sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ==} + yuku-ast@0.8.7: + resolution: {integrity: sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ==} + yuku-codegen@0.10.2: resolution: {integrity: sha512-hentl2dtrF6cPjiAirtkfxfFZBA6Hdm61UqRJ7cA0qrlDNMk9PZGOjw5w1mx3E0hu/6pHcJF4dJW+D0SXHPZOA==} + yuku-codegen@0.8.7: + resolution: {integrity: sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw==} + yuku-parser@0.10.2: resolution: {integrity: sha512-CgaU0/PPjCAIEZ3WQroosOxTY3eeKldAN3h+vk8pMNz4+jl1CZzBR4pW+K/rRPF112VooCL5FdjJoiMNjDrL2A==} + yuku-parser@0.8.7: + resolution: {integrity: sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ==} + z-schema@5.0.5: resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} engines: {node: '>=8.0.0'} @@ -4828,6 +5420,11 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} + '@alcalzone/ansi-tokenize@0.3.1': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@andrewbranch/untar.js@1.0.4': {} '@arethetypeswrong/core@0.18.5': @@ -5734,6 +6331,14 @@ snapshots: '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 + '@inkjs/ui@2.0.0(ink@8.0.0(@types/react@19.3.0)(react@19.3.0))': + dependencies: + chalk: 5.6.2 + cli-spinners: 3.4.0 + deepmerge: 4.3.1 + figures: 6.1.0 + ink: 8.0.0(@types/react@19.3.0)(react@19.3.0) + '@internationalized/date@3.12.4': dependencies: '@swc/helpers': 0.5.23 @@ -6210,6 +6815,8 @@ snapshots: '@simple-libs/stream-utils@2.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -6339,6 +6946,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/gradient-string@1.1.6': + dependencies: + '@types/tinycolor2': 1.4.6 + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -6347,6 +6958,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + '@types/node@25.9.9': dependencies: undici-types: 7.24.6 @@ -6368,6 +6983,8 @@ snapshots: '@types/cookiejar': 2.1.5 '@types/node': 26.6.4 + '@types/tinycolor2@1.4.6': {} + '@types/trusted-types@2.0.7': {} '@types/unist@3.0.3': {} @@ -6389,12 +7006,29 @@ snapshots: tinyrainbow: 3.2.0 vitest: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2(@noble/hashes@1.8.0))(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.3.0 + tinyrainbow: 3.2.0 + '@vitest/istanbul-lib-coverage@1.0.2': {} '@vitest/istanbul-lib-report@1.0.2': dependencies: '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/mocker@4.1.11(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) + '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -6404,8 +7038,32 @@ snapshots: optionalDependencies: vite: 8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.2.0 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + '@vitest/spy@5.0.3': {} + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + '@walletconnect/core@2.25.0(typescript@5.9.3)(zod@4.6.5)': dependencies: '@walletconnect/heartbeat': 1.2.2 @@ -6672,77 +7330,151 @@ snapshots: '@yuku-codegen/binding-android-arm64@0.10.2': optional: true + '@yuku-codegen/binding-android-arm64@0.8.7': + optional: true + '@yuku-codegen/binding-darwin-arm64@0.10.2': optional: true + '@yuku-codegen/binding-darwin-arm64@0.8.7': + optional: true + '@yuku-codegen/binding-darwin-x64@0.10.2': optional: true + '@yuku-codegen/binding-darwin-x64@0.8.7': + optional: true + '@yuku-codegen/binding-freebsd-x64@0.10.2': optional: true + '@yuku-codegen/binding-freebsd-x64@0.8.7': + optional: true + '@yuku-codegen/binding-linux-arm-gnu@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + optional: true + '@yuku-codegen/binding-linux-arm-musl@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + optional: true + '@yuku-codegen/binding-linux-arm64-gnu@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + optional: true + '@yuku-codegen/binding-linux-arm64-musl@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + optional: true + '@yuku-codegen/binding-linux-x64-gnu@0.10.2': optional: true + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + optional: true + '@yuku-codegen/binding-linux-x64-musl@0.10.2': optional: true + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + optional: true + '@yuku-codegen/binding-win32-arm64@0.10.2': optional: true + '@yuku-codegen/binding-win32-arm64@0.8.7': + optional: true + '@yuku-codegen/binding-win32-x64@0.10.2': optional: true + '@yuku-codegen/binding-win32-x64@0.8.7': + optional: true + '@yuku-parser/binding-android-arm64@0.10.2': optional: true + '@yuku-parser/binding-android-arm64@0.8.7': + optional: true + '@yuku-parser/binding-darwin-arm64@0.10.2': optional: true + '@yuku-parser/binding-darwin-arm64@0.8.7': + optional: true + '@yuku-parser/binding-darwin-x64@0.10.2': optional: true + '@yuku-parser/binding-darwin-x64@0.8.7': + optional: true + '@yuku-parser/binding-freebsd-x64@0.10.2': optional: true + '@yuku-parser/binding-freebsd-x64@0.8.7': + optional: true + '@yuku-parser/binding-linux-arm-gnu@0.10.2': optional: true + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + optional: true + '@yuku-parser/binding-linux-arm-musl@0.10.2': optional: true + '@yuku-parser/binding-linux-arm-musl@0.8.7': + optional: true + '@yuku-parser/binding-linux-arm64-gnu@0.10.2': optional: true + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + optional: true + '@yuku-parser/binding-linux-arm64-musl@0.10.2': optional: true + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + optional: true + '@yuku-parser/binding-linux-x64-gnu@0.10.2': optional: true + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + optional: true + '@yuku-parser/binding-linux-x64-musl@0.10.2': optional: true + '@yuku-parser/binding-linux-x64-musl@0.8.7': + optional: true + '@yuku-parser/binding-win32-arm64@0.10.2': optional: true + '@yuku-parser/binding-win32-arm64@0.8.7': + optional: true + '@yuku-parser/binding-win32-x64@0.10.2': optional: true + '@yuku-parser/binding-win32-x64@0.8.7': + optional: true + '@yuku-toolchain/types@0.10.2': {} + '@yuku-toolchain/types@0.8.7': {} + '@zag-js/accordion@1.43.3': dependencies: '@zag-js/anatomy': 1.43.3 @@ -7408,6 +8140,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.4.0: {} @@ -7420,6 +8156,10 @@ snapshots: ansi-styles@6.2.3: {} + ansi-styles@7.0.0: {} + + ansis@4.4.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -7462,6 +8202,8 @@ snapshots: atomic-sleep@1.0.0: {} + auto-bind@5.0.1: {} + axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): dependencies: follow-redirects: 1.16.1(debug@4.4.3(supports-color@8.1.1)) @@ -7591,6 +8333,10 @@ snapshots: chai@6.3.0: {} + chalk@5.6.2: {} + + chalk@6.0.1: {} + chokidar@5.0.0: dependencies: readdirp: 5.1.1 @@ -7599,6 +8345,19 @@ snapshots: cjs-module-lexer@1.4.3: {} + cli-boxes@4.0.1: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-spinners@3.4.0: {} + + cli-truncate@6.1.1: + dependencies: + slice-ansi: 9.0.1 + string-width: 8.3.0 + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -7611,6 +8370,10 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -7647,6 +8410,10 @@ snapshots: '@simple-libs/stream-utils': 2.0.0 argue-cli: 3.2.0 + convert-source-map@2.0.0: {} + + convert-to-spaces@2.0.1: {} + cookie-es@1.2.3: {} cookie-signature@1.2.2: {} @@ -7730,6 +8497,8 @@ snapshots: deep-extend@0.6.0: {} + deepmerge@4.3.1: {} + defu@6.1.7: {} delayed-stream@1.0.0: {} @@ -7805,6 +8574,8 @@ snapshots: env-paths@2.2.1: {} + environment@1.1.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -7863,6 +8634,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@2.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -7950,6 +8723,10 @@ snapshots: fflate@0.8.3: {} + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-uri-to-path@1.0.0: {} finalhandler@2.1.1(supports-color@8.1.1): @@ -8038,6 +8815,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + get-tsconfig@5.0.0-beta.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -8052,6 +8833,11 @@ snapshots: graceful-fs@4.2.11: {} + gradient-string@3.0.0: + dependencies: + chalk: 5.6.2 + tinygradient: 1.1.5 + h3@1.15.11: dependencies: cookie-es: 1.2.3 @@ -8066,6 +8852,8 @@ snapshots: has-flag@4.0.0: {} + has-flag@5.0.1: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -8133,12 +8921,60 @@ snapshots: indent-string@4.0.0: {} + indent-string@5.0.0: {} + inherits@2.0.4: {} ini@1.3.8: {} ini@6.0.0: {} + ink-gradient@4.0.1(ink@8.0.0(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + dependencies: + '@types/gradient-string': 1.1.6 + gradient-string: 3.0.0 + ink: 8.0.0(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + strip-ansi: 7.2.0 + + ink-link@5.0.0(ink@8.0.0(@types/react@19.3.0)(react@19.3.0)): + dependencies: + ink: 8.0.0(@types/react@19.3.0)(react@19.3.0) + terminal-link: 5.0.0 + + ink-testing-library@4.0.0(@types/react@19.3.0): + optionalDependencies: + '@types/react': 19.3.0 + + ink@8.0.0(@types/react@19.3.0)(react@19.3.0): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.1 + ansi-escapes: 7.3.0 + ansi-styles: 7.0.0 + auto-bind: 5.0.1 + chalk: 6.0.1 + cli-boxes: 4.0.1 + cli-cursor: 4.0.0 + cli-truncate: 6.1.1 + code-excerpt: 4.0.0 + es-toolkit: 1.52.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + quick-lru: 7.3.0 + react: 19.3.0 + react-reconciler: 0.34.0(react@19.3.0) + signal-exit: 3.0.7 + stack-utils: 2.0.6 + string-width: 8.3.0 + terminal-size: 4.0.1 + type-fest: 5.10.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.2 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.3.0 + ip-address@10.7.3: {} ipaddr.js@1.9.1: {} @@ -8153,12 +8989,20 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.7.0 + + is-in-ci@2.0.0: {} + is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} + is-unicode-supported@2.1.0: {} + jiti@2.6.1: {} jiti@2.7.0: {} @@ -8487,6 +9331,8 @@ snapshots: mime@1.6.0: {} + mimic-fn@2.1.0: {} + mimic-response@3.1.0: {} min-indent@1.0.1: {} @@ -8566,6 +9412,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + openapi-fetch@0.17.0: dependencies: openapi-typescript-helpers: 0.1.0 @@ -8670,12 +9520,16 @@ snapshots: parseurl@1.3.3: {} + patch-console@2.0.0: {} + path-exists@4.0.0: {} path-parse@1.0.7: {} path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + perfect-freehand@1.2.3: {} pg-cloudflare@1.4.1: @@ -8891,6 +9745,11 @@ snapshots: react-is@17.0.2: {} + react-reconciler@0.34.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + react-router-dom@7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: react: 19.3.0 @@ -8943,6 +9802,25 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + rolldown-plugin-dts@0.27.14(oxc-resolver@11.24.2)(rolldown@1.2.12)(typescript@5.9.3): + dependencies: + dts-resolver: 3.0.0(oxc-resolver@11.24.2) + get-tsconfig: 5.0.0-beta.5 + obug: 2.2.1 + rolldown: 1.2.12 + yuku-ast: 0.8.7 + yuku-codegen: 0.8.7 + yuku-parser: 0.8.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver + rolldown-plugin-dts@0.28.6(oxc-resolver@11.24.2)(rolldown@1.2.12)(typescript@5.9.3): dependencies: dts-resolver: 3.0.0(oxc-resolver@11.24.2) @@ -9136,6 +10014,10 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + simple-concat@1.0.1: {} simple-get@4.0.1: @@ -9144,6 +10026,11 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + slice-ansi@9.0.1: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + slow-redact@0.3.2: {} smol-toml@1.9.0: {} @@ -9158,6 +10045,12 @@ snapshots: sprintf-js@1.0.3: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackback@0.0.2: {} + statuses@2.0.2: {} std-env@4.3.0: {} @@ -9201,10 +10094,17 @@ snapshots: strip-json-comments@5.0.3: {} + supports-color@10.2.2: {} + supports-color@8.1.1: dependencies: has-flag: 4.0.0 + supports-hyperlinks@4.6.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + supports-preserve-symlinks-flag@1.0.0: {} tagged-tag@1.0.0: {} @@ -9228,6 +10128,13 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + terminal-link@5.0.0: + dependencies: + ansi-escapes: 7.3.0 + supports-hyperlinks: 4.6.0 + + terminal-size@4.0.1: {} + thread-stream@3.2.0: dependencies: real-require: 0.2.0 @@ -9236,8 +10143,12 @@ snapshots: dependencies: real-require: 1.0.0 + tinybench@2.9.0: {} + tinybench@6.2.0: {} + tinycolor2@1.6.0: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: @@ -9245,6 +10156,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinygradient@1.1.5: + dependencies: + '@types/tinycolor2': 1.4.6 + tinycolor2: 1.6.0 + tinyrainbow@3.2.0: {} tldts-core@7.4.16: {} @@ -9267,6 +10183,33 @@ snapshots: tree-kill@1.2.2: {} + tsdown@0.22.14(@arethetypeswrong/core@0.18.5)(oxc-resolver@11.24.2)(publint@0.3.25)(typescript@5.9.3): + dependencies: + ansis: 4.4.0 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.1.0 + hookable: 6.1.2 + import-without-cache: 0.4.1 + obug: 2.2.1 + picomatch: 4.0.7 + rolldown: 1.2.12 + rolldown-plugin-dts: 0.27.14(oxc-resolver@11.24.2)(rolldown@1.2.12)(typescript@5.9.3) + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + '@arethetypeswrong/core': 0.18.5 + publint: 0.3.25 + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc + tsdown@0.23.0(@arethetypeswrong/core@0.18.5)(oxc-resolver@11.24.2)(publint@0.3.25)(typescript@5.9.3): dependencies: cac: 7.0.0 @@ -9413,6 +10356,8 @@ snapshots: vary@1.1.2: {} + verkit@0.3.2: {} + verkit@0.4.1: {} vite-express@0.22.1(supports-color@8.1.1): @@ -9437,6 +10382,20 @@ snapshots: lightningcss: 1.33.0 yaml: 2.9.1 + vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.12 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.1 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.1 + vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 @@ -9451,6 +10410,34 @@ snapshots: jiti: 2.7.0 yaml: 2.9.1 + vitest@4.1.11(@types/node@24.19.1)(jsdom@30.1.2(@noble/hashes@1.8.0))(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.2.1 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.2.0 + vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.19.1 + jsdom: 30.1.2(@noble/hashes@1.8.0) + transitivePeerDependencies: + - msw + vitest@5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2(@noble/hashes@1.8.0))(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 @@ -9501,8 +10488,22 @@ snapshots: which-module@2.0.1: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + why-is-node-running@3.2.1: {} + widest-line@6.0.0: + dependencies: + string-width: 8.3.0 + + wrap-ansi@10.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.3.0 + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -9565,10 +10566,16 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + yoga-layout@3.2.1: {} + yuku-ast@0.10.2: dependencies: '@yuku-toolchain/types': 0.10.2 + yuku-ast@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + yuku-codegen@0.10.2: dependencies: '@yuku-toolchain/types': 0.10.2 @@ -9586,6 +10593,23 @@ snapshots: '@yuku-codegen/binding-win32-arm64': 0.10.2 '@yuku-codegen/binding-win32-x64': 0.10.2 + yuku-codegen@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-x64': 0.8.7 + '@yuku-codegen/binding-freebsd-x64': 0.8.7 + '@yuku-codegen/binding-linux-arm-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm-musl': 0.8.7 + '@yuku-codegen/binding-linux-arm64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm64-musl': 0.8.7 + '@yuku-codegen/binding-linux-x64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-x64-musl': 0.8.7 + '@yuku-codegen/binding-win32-arm64': 0.8.7 + '@yuku-codegen/binding-win32-x64': 0.8.7 + yuku-parser@0.10.2: dependencies: '@yuku-toolchain/types': 0.10.2 @@ -9604,6 +10628,24 @@ snapshots: '@yuku-parser/binding-win32-arm64': 0.10.2 '@yuku-parser/binding-win32-x64': 0.10.2 + yuku-parser@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + yuku-ast: 0.8.7 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.8.7 + '@yuku-parser/binding-darwin-arm64': 0.8.7 + '@yuku-parser/binding-darwin-x64': 0.8.7 + '@yuku-parser/binding-freebsd-x64': 0.8.7 + '@yuku-parser/binding-linux-arm-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm-musl': 0.8.7 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm64-musl': 0.8.7 + '@yuku-parser/binding-linux-x64-gnu': 0.8.7 + '@yuku-parser/binding-linux-x64-musl': 0.8.7 + '@yuku-parser/binding-win32-arm64': 0.8.7 + '@yuku-parser/binding-win32-x64': 0.8.7 + z-schema@5.0.5: dependencies: lodash.get: 4.4.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3e5401ba..84225954 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,8 +5,13 @@ packages: - canton-connect - canton-dappbooster - canton-theme - - dapp/daml - - dapp/frontend + - canton-create + - canton-create/scaffold/* + - example-dapps/* + +# Only the localnet layer declares it; hoisted so the root scripts and `pnpm exec` reach that copy. +publicHoistPattern: + - '@bootnodedev/canton-barebones' allowBuilds: better-sqlite3: true diff --git a/kit/check-anatomy.mjs b/scripts/check-anatomy.mjs similarity index 98% rename from kit/check-anatomy.mjs rename to scripts/check-anatomy.mjs index 153b031f..5984b38f 100644 --- a/kit/check-anatomy.mjs +++ b/scripts/check-anatomy.mjs @@ -10,13 +10,13 @@ // because a live region carries its own inline sr-only. What is never legitimate is a component the // theme ignores entirely, so each anatomy object has to be reached by at least one selector. // -// Usage: node kit/check-anatomy.mjs +// Usage: node scripts/check-anatomy.mjs import { existsSync, readdirSync, readFileSync } from 'node:fs' import path from 'node:path' import postcss from 'postcss' import ts from 'typescript' -import { createGate, repoRoot } from '../scripts/lib/gate.mjs' +import { createGate, repoRoot } from './gate.mjs' // A provider is here because ThemeProvider writes `data-theme` to <html>: it renders no markup, but // it does place a selector the theme keys on, so it owes an anatomy like any component. diff --git a/kit/check-versions.mjs b/scripts/check-versions.mjs similarity index 96% rename from kit/check-versions.mjs rename to scripts/check-versions.mjs index 06fc2603..49ca15e2 100644 --- a/kit/check-versions.mjs +++ b/scripts/check-versions.mjs @@ -7,12 +7,12 @@ // Given a version argument it also requires the root and every library to be on it, which is how // .github/workflows/release.yml refuses a release whose tag and manifests disagree. // -// Usage: node kit/check-versions.mjs [expected-version] +// Usage: node scripts/check-versions.mjs [expected-version] import { readFileSync } from 'node:fs' import path from 'node:path' import ts from 'typescript' -import { createGate, repoRoot } from '../scripts/lib/gate.mjs' +import { createGate, repoRoot } from './gate.mjs' import { DEPENDENCY_FIELDS, libraryNames, ROOT_MANIFEST, readManifests } from './manifests.mjs' // The gate's contract is `file:line message`, and JSON.parse keeps no positions, so the line comes diff --git a/kit/check-versions.test.mjs b/scripts/check-versions.test.mjs similarity index 80% rename from kit/check-versions.test.mjs rename to scripts/check-versions.test.mjs index 7ba8cdb8..ddb92ead 100644 --- a/kit/check-versions.test.mjs +++ b/scripts/check-versions.test.mjs @@ -3,8 +3,6 @@ import { describe, it } from 'node:test' import { findDrift } from './check-versions.mjs' import { readManifests } from './manifests.mjs' -// A test rather than a second command in the root `test` script, which would name a file a consumer -// scaffold deletes. See the `kit/` section of root CLAUDE.md. const manifests = readManifests() describe('version lockstep', () => { diff --git a/kit/docs-check.mjs b/scripts/docs-check.mjs similarity index 99% rename from kit/docs-check.mjs rename to scripts/docs-check.mjs index 1749a3ec..102c4820 100644 --- a/kit/docs-check.mjs +++ b/scripts/docs-check.mjs @@ -8,12 +8,12 @@ // vocabulary is typedoc's outright — typedoc.shared.json is the allow-list. See the Doc blocks // section of root CLAUDE.md for both splits. // -// Usage: node kit/docs-check.mjs +// Usage: node scripts/docs-check.mjs import { existsSync, readdirSync, readFileSync } from 'node:fs' import path from 'node:path' import ts from 'typescript' -import { createGate, repoRoot } from '../scripts/lib/gate.mjs' +import { createGate, repoRoot } from './gate.mjs' const readJson = (file) => JSON.parse(readFileSync(file, 'utf8')) diff --git a/scripts/lib/gate.mjs b/scripts/gate.mjs similarity index 97% rename from scripts/lib/gate.mjs rename to scripts/gate.mjs index 0be9e279..d556e0e4 100644 --- a/scripts/lib/gate.mjs +++ b/scripts/gate.mjs @@ -4,7 +4,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' -export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') export const createGate = (name) => { const findings = [] diff --git a/scripts/manifests.mjs b/scripts/manifests.mjs new file mode 100644 index 00000000..46509fde --- /dev/null +++ b/scripts/manifests.mjs @@ -0,0 +1,45 @@ +// The release script and the version gate need the same view of the workspace: every manifest, and +// which of them are the published libraries. + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { repoRoot } from './gate.mjs' + +export const ROOT_MANIFEST = 'package.json' + +export const DEPENDENCY_FIELDS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] + +// pnpm's own list, so nothing `pnpm -r publish` publishes is missing from the bump or the check. +const workspaceDirs = (root) => + JSON.parse( + execFileSync('pnpm', ['ls', '-r', '--depth', '-1', '--json'], { cwd: root, encoding: 'utf8' }), + ).map((workspace) => workspace.path) + +/** Every workspace manifest, parsed, keyed by its repo-relative path. */ +export const readManifests = (root = repoRoot) => { + // pnpm reports real paths, even for a root reached through a symlink. + const base = fs.realpathSync(root) + return Object.fromEntries( + workspaceDirs(root).map((dir) => [ + path.join(path.relative(base, dir), 'package.json'), + JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')), + ]), + ) +} + +/** + * The published packages, by package name. A workspace manifest is one unless it is private, + * which is what leaves the root and the `canton-create/scaffold/` folders out of the lockstep. + */ +export const libraryNames = (manifests) => + new Set( + Object.values(manifests) + .filter((manifest) => manifest.private !== true && typeof manifest.name === 'string') + .map((manifest) => manifest.name), + ) diff --git a/scripts/manifests.test.mjs b/scripts/manifests.test.mjs new file mode 100644 index 00000000..07c17e67 --- /dev/null +++ b/scripts/manifests.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { after, describe, it } from 'node:test' +import { readManifests } from './manifests.mjs' + +describe('readManifests', () => { + it('reads every package pnpm finds in the workspace', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'manifests-')) + after(() => fs.rmSync(root, { recursive: true, force: true })) + fs.mkdirSync(path.join(root, 'example-dapps/probe'), { recursive: true }) + fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), 'packages:\n - example-dapps/*\n') + fs.writeFileSync(path.join(root, 'package.json'), '{"name":"root","private":true}') + fs.writeFileSync( + path.join(root, 'example-dapps/probe/package.json'), + '{"name":"probe","version":"0.1.0"}', + ) + + assert.deepEqual(Object.keys(readManifests(root)).sort(), [ + 'example-dapps/probe/package.json', + 'package.json', + ]) + }) +}) diff --git a/kit/release-version.mjs b/scripts/release-version.mjs similarity index 96% rename from kit/release-version.mjs rename to scripts/release-version.mjs index 7c82b572..a361647d 100644 --- a/kit/release-version.mjs +++ b/scripts/release-version.mjs @@ -4,7 +4,7 @@ // pushes and opens a *draft* GitHub release. Publishing that draft is the one irreversible step and // stays a human click: .github/workflows/release.yml runs on `release: published`. // -// Usage: node kit/release-version.mjs 0.4.0 +// Usage: node scripts/release-version.mjs 0.4.0 // // Spell the version as a plain argument. `pnpm run release:version -- 0.4.0` forwards the separator // into argv, so the version arrives as `--` (the deploy-dar note in CLAUDE.md); that is rejected @@ -12,7 +12,7 @@ import { execFileSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' -import { repoRoot } from '../scripts/lib/gate.mjs' +import { repoRoot } from './gate.mjs' import { DEPENDENCY_FIELDS, libraryNames, ROOT_MANIFEST, readManifests } from './manifests.mjs' // semver.org's own regex, minus the named groups: a build-metadata or prerelease version has to @@ -23,7 +23,7 @@ const SEMVER = export const parseVersion = (argument) => { if (typeof argument !== 'string' || !SEMVER.test(argument)) { throw new Error( - `not a version: ${argument ?? '(none)'}. Usage: node kit/release-version.mjs 0.4.0`, + `not a version: ${argument ?? '(none)'}. Usage: node scripts/release-version.mjs 0.4.0`, ) } return argument diff --git a/kit/release-version.test.mjs b/scripts/release-version.test.mjs similarity index 100% rename from kit/release-version.test.mjs rename to scripts/release-version.test.mjs diff --git a/kit/typedoc.json b/scripts/typedoc.json similarity index 100% rename from kit/typedoc.json rename to scripts/typedoc.json diff --git a/kit/typedoc.shared.json b/scripts/typedoc.shared.json similarity index 100% rename from kit/typedoc.shared.json rename to scripts/typedoc.shared.json