fixing documentation
Publish snapshot / snapshot (push) Successful in 1m2s

This commit is contained in:
Benjamin Diedrichsen
2026-07-29 13:21:04 +02:00
parent 7e703c93b1
commit 4c0fe528dc
14 changed files with 74 additions and 74 deletions
+1 -1
View File
@@ -116,7 +116,7 @@ jobs:
# Pass 1: stamp every manifest before anything is packed. `pnpm
# publish` substitutes `workspace:*` with the version the linked
# package declares at pack time, so nopy-cube has to be carrying its
# package declares at pack time, so nopy-cubes has to be carrying its
# snapshot version by the time nopy is packed.
for dir in $dirs; do
base=$(node -p "require('./${dir}/package.json').version")
+3 -3
View File
@@ -1,13 +1,13 @@
# Tag-driven release of a single package.
#
# git tag nopy-v1.2.0 && git push origin nopy-v1.2.0
# git tag nopy-cube-v1.2.0 && git push origin nopy-cube-v1.2.0
# git tag nopy-cubes-v1.2.0 && git push origin nopy-cubes-v1.2.0
# git tag keyman-v1.2.0 && git push origin keyman-v1.2.0
#
# The tag is the source of truth for *which* package ships; package.json is the
# source of truth for the version, and the two must agree or the run fails.
#
# Packages that link to each other release dependency-first — `nopy-cube` before
# Packages that link to each other release dependency-first — `nopy-cubes` before
# `nopy` — because the linked version is resolved at pack time. The run refuses
# to publish otherwise.
# A version with a prerelease part (1.2.0-rc.1) publishes under `next` instead
@@ -135,7 +135,7 @@ jobs:
# package declares at this commit. If that version is not on the
# registry yet, the release installs to a broken tree — and npmjs
# only lets you unpublish for 72 hours. Release the dependency first:
# nopy-cube, then nopy, then any bundle.
# nopy-cubes, then nopy, then any bundle.
#
# npmjs only: it is the irreversible one, and it needs no credentials
# to read, which this step does not have yet.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"hosts": [],
"cubeDirs": [],
"cubePackages": ["@bitsquare/cubes-core"],
"cubePackages": ["@bitsquare/nopy-cubes-core"],
"env": {},
"log": {
"verbosity": "info",
+16 -16
View File
@@ -8,17 +8,17 @@ A pnpm workspace holding two independently published CLIs, the authoring package
their deployment units are written against, and one bundle of those units:
| Path | Package | Binary | Role |
| --------------------- | ---------------------- | -------- | -------------------------------------------------------- |
| -------------------------- | ----------------------------- | -------- | -------------------------------------------------------- |
| `packages/nopy` | `@bitsquare/nopy` | `nopy` | interactive pyinfra script management and execution |
| `packages/keyman` | `@bitsquare/keyman` | `keyman` | SSH key management, shelling out to `age` / `ssh-keygen` |
| `packages/nopy-cube` | `@bitsquare/nopy-cube` | — | the authoring surface a `manifest.mjs` imports |
| `packages/cubes-core` | `@bitsquare/cubes-core`| — | the core cube bundle (22 cubes), no TypeScript |
| `packages/nopy-cubes` | `@bitsquare/nopy-cubes` | — | the authoring surface a `manifest.mjs` imports |
| `packages/nopy-cubes-core` | `@bitsquare/nopy-cubes-core` | — | the core cube bundle (22 cubes), no TypeScript |
The root package is private; everything under `packages/` ships. `keyman` stands
alone, but `nopy` and `cubes-core` both depend on `nopy-cube` (`workspace:*`), so
alone, but `nopy` and `nopy-cubes-core` both depend on `nopy-cubes` (`workspace:*`), so
publish order matters — see *Releasing*.
`cubes-core` is consumed the way a third party would consume it: the root
`nopy-cubes-core` is consumed the way a third party would consume it: the root
`.nopyrc.json` names it in `cubePackages`, and the loader reads it out of
`node_modules`. There is no `cubes/` directory at the repo root any more.
@@ -76,11 +76,11 @@ locally and on the runner. Barrel files (`src/index.ts`, `src/cubes/index.ts`,
`src/nopy.cubes.ts`) and the Commander argv wiring (`src/*.cli.ts`) are excluded;
adding logic to those files means moving it somewhere covered.
nopy's vitest config aliases `@bitsquare/nopy-cube` to that package's **source**,
nopy's vitest config aliases `@bitsquare/nopy-cubes` to that package's **source**,
not to the workspace link (which points at a `dist` that only exists after a
build), so the gate does not depend on build ordering and can never run against a
stale artefact. The same config excludes `**/nopy-cube/**` from coverage — without
it nopy's numbers absorb another package's files. `cubes-core` has no tests of its
stale artefact. The same config excludes `**/nopy-cubes/**` from coverage — without
it nopy's numbers absorb another package's files. `nopy-cubes-core` has no tests of its
own; the loader tests in nopy cover the contract it implements.
The three TS packages set `pool: 'forks'` because tests use `process.chdir()` — most
@@ -184,15 +184,15 @@ items 6 and 7.
A cube directory holds `manifest.mjs` + `deploy.py`; anything else in it is
ignored by the loader but reachable from the script, which runs with the cube
directory as its cwd. Manifests are ESM, import `Manifest` from
`@bitsquare/nopy-cube`, and declare `id`, `name`, a Zod `schema` (each field
`@bitsquare/nopy-cubes`, and declare `id`, `name`, a Zod `schema` (each field
`.describe()`d — the description is the prompt label — and `.default()`ed), plus
optional `secrets`/`dependencies`/`before`/`after`.
Import from **`@bitsquare/nopy-cube`**, not `@bitsquare/nopy`. The authoring
Import from **`@bitsquare/nopy-cubes`**, not `@bitsquare/nopy`. The authoring
surface is types and a factory, with zod as its only peer — no CLI, no prompts,
no process spawning — so a bundle can depend on it without dragging the CLI in.
`@bitsquare/nopy` re-exports all of it (`cubes.Manifest`, `cubes.uniqid`, …), so
the older form still works; every cube in `packages/cubes-core` has been moved to
the older form still works; every cube in `packages/nopy-cubes-core` has been moved to
the new one.
Manifests are resolved by ordinary Node resolution **from the manifest's own
@@ -200,7 +200,7 @@ directory**, which used to mean a hand-written local cube failed with
`ERR_MODULE_NOT_FOUND` unless you linked the package. `cubes/resolve-hook.mjs`
retires that: `loadCubes()` registers a `module.register()` resolve hook that
tries normal resolution *first* and only on failure falls back to resolving
`@bitsquare/nopy-cube`, `@bitsquare/nopy` and `zod` from the running CLI's own
`@bitsquare/nopy-cubes`, `@bitsquare/nopy` and `zod` from the running CLI's own
`node_modules`. Ordinary-resolution-first is the load-bearing part — a cube that
ships its own zod keeps it. The hook is a convenience, never load-bearing:
registration is wrapped in a `try`, and a bundle installed properly never reaches
@@ -242,7 +242,7 @@ The install command uses `--@bitsquare:registry=<url>`, never `--registry`:
Gitea serves the `@bitsquare` scope and does **not** proxy npmjs, so a global
`--registry` would send every transitive dependency to a registry that has never
heard of them. Verified — `npm i -g @bitsquare/nopy@main --@bitsquare:registry=…`
pulls `nopy-cube` from Gitea and the other 55 packages from npmjs. pnpm accepts
pulls `nopy-cubes` from Gitea and the other 55 packages from npmjs. pnpm accepts
the same flag; the `npm_config_@bitsquare:registry` env var does not work with
pnpm and is not used.
@@ -330,7 +330,7 @@ Three things the `workspace:*` links added, all of them non-obvious:
`workspace:` range survived into a tarball. It runs in both publish workflows.
Note `pnpm pack` has no `--ignore-scripts` flag, so `prepack` does rebuild —
which means the artefact under test is the one publish ships.
- **Order.** `packages/*/` sorts `nopy` before `nopy-cube`, which is backwards.
- **Order.** `packages/*/` sorts `nopy` before `nopy-cubes`, which is backwards.
`scripts/publish-order.mjs` topologically sorts over the `workspace:` edges;
the snapshot workflow stamps *every* version first and only then publishes in
that order, because `pnpm publish` reads the linked package's version at pack
@@ -347,9 +347,9 @@ currently have no effect. Treat `docs/REFACTORING.md` as a plan, not a record.
The publish lane has now run against the Gitea registry: all four packages are
there under `@main`, and `pnpm run try:snapshot` installs them into a throwaway
project with npm and runs the binary. The npmjs lane has only ever published
`@bitsquare/nopy`; `keyman`, `nopy-cube` and `cubes-core` have never been
`@bitsquare/nopy`; `keyman`, `nopy-cubes` and `nopy-cubes-core` have never been
released there, so the *check linked deps are released* guard in `release.yml`
will stop the first `nopy` release until `nopy-cube` ships.
will stop the first `nopy` release until `nopy-cubes` ships.
Nothing checks that a bundle and the CLI reading it are compatible versions;
`nopy.engines` was considered and deferred. `docs/CUBE-PACKAGES.md` is where all
+1 -1
View File
@@ -206,7 +206,7 @@ Two pairs are inverted, and both have consequences:
> to deploy a cube whose required key nothing supplied. Verified against all 22
> cubes in `cubes/`: 19 build a complete `-D` run, the 3 below abort by name.
>
> Re-measured against the 25 cubes now in `packages/cubes-core/cubes`: 20 build a
> Re-measured against the 25 cubes now in `packages/nopy-cubes-core/cubes`: 20 build a
> complete `-D` run and 5 abort by name. Two of the additions are deliberate —
> `user:add` lost its `PUBKEY` default (it was a specific personal key), and
> `ssh:keygen` inherits that failure because it declares `dependencies: () =>
+23 -23
View File
@@ -28,8 +28,8 @@ shipped. If you only want to cut a release, jump to
| --------------------- | ----------------------- | -------- | ------------------------ |
| `packages/nopy` | `@bitsquare/nopy` | `nopy` | CLI |
| `packages/keyman` | `@bitsquare/keyman` | `keyman` | CLI |
| `packages/nopy-cube` | `@bitsquare/nopy-cube` | — | library (cube authoring) |
| `packages/cubes-core` | `@bitsquare/cubes-core` | — | cube bundle (no build) |
| `packages/nopy-cubes` | `@bitsquare/nopy-cubes` | — | library (cube authoring) |
| `packages/nopy-cubes-core` | `@bitsquare/nopy-cubes-core` | — | cube bundle (no build) |
All are ESM and declare `engines.node >= 22`. The two CLIs expose a single
executable through `bin`, so `npm install -g` puts `nopy` / `keyman` on the
@@ -37,7 +37,7 @@ executable through `bin`, so `npm install -g` puts `nopy` / `keyman` on the
The tarball contents are pinned by `files` — for the three TypeScript packages
that is `["dist", "README.md", "LICENSE"]`, so sources and tests are not shipped.
`cubes-core` ships `["cubes", "!cubes/**/*.log", "README.md", "LICENSE"]`: the
`nopy-cubes-core` ships `["cubes", "!cubes/**/*.log", "README.md", "LICENSE"]`: the
negation matters, because a cube that has been run leaves a `pyinfra-debug.log`
next to its `deploy.py`, and `.gitignore` does not filter an npm tarball.
`publishConfig.access: "public"` is what makes a scoped package publishable to
@@ -45,7 +45,7 @@ npmjs without an extra flag; the workflows pass `--access public` anyway.
### Dependencies between them
`keyman` stands alone. `nopy` and `cubes-core` both depend on `nopy-cube` through
`keyman` stands alone. `nopy` and `nopy-cubes-core` both depend on `nopy-cubes` through
`workspace:*`, which drives three rules the rest of this document keeps coming
back to:
@@ -55,7 +55,7 @@ back to:
through verbatim and the install fails with `EUNSUPPORTEDPROTOCOL`. `pnpm
pack` and `pnpm publish` substitute the concrete version at pack time. Both
workflows use `pnpm publish --ignore-scripts --no-git-checks`.
2. **`nopy-cube` publishes before anything that depends on it.**
2. **`nopy-cubes` publishes before anything that depends on it.**
`node scripts/publish-order.mjs` prints the publishable directories in
dependency order — note that plain alphabetical `packages/*/` gets this
backwards, putting `nopy` first.
@@ -124,7 +124,7 @@ The publish step is **two passes** over `node scripts/publish-order.mjs`: the
first stamps the snapshot version into every manifest with `npm pkg set`, the
second publishes. They cannot be one loop — `pnpm publish` reads a linked
package's version out of its manifest at pack time, so stamping and publishing
one package at a time would bake the *old* `nopy-cube` version into `nopy`'s
one package at a time would bake the *old* `nopy-cubes` version into `nopy`'s
tarball.
### `release.yml`
@@ -144,7 +144,7 @@ the whole gate.
*Check linked deps are released* asks npmjs whether every `workspace:` dependency
of the package being released already exists at the version pnpm is about to
bake in (`scripts/linked-deps.mjs``npm view`). Tagging `nopy-v1.3.0` while
`@bitsquare/nopy-cube@1.1.0` is still unpublished would otherwise ship a tarball
`@bitsquare/nopy-cubes@1.1.0` is still unpublished would otherwise ship a tarball
nobody can install, and npmjs only lets you unpublish for 72 hours. The check is
npmjs-only: it runs before any credentials are written, and npmjs is the registry
where the mistake is permanent.
@@ -253,26 +253,26 @@ the same way:
```sh
git tag nopy-v1.2.0
git tag keyman-v1.2.0
git tag nopy-cube-v1.2.0
git tag cubes-core-v1.2.0
git tag nopy-cubes-v1.2.0
git tag nopy-cubes-core-v1.2.0
```
### Ordering when more than one package changed
Tags are independent, but the dependency graph is not. If a release touches
`nopy-cube` *and* something that depends on it, release them in this order,
`nopy-cubes` *and* something that depends on it, release them in this order,
waiting for each run to go green:
```
nopy-cube → nopy, cubes-core (these two are independent of each other)
nopy-cubes → nopy, nopy-cubes-core (these two are independent of each other)
```
Release `nopy` first and the run stops at the *check linked deps* step, telling
you the `nopy-cube` version it wanted is not on npmjs. That is the guard working;
release `nopy-cube`, then re-tag. `node scripts/publish-order.mjs` prints the
you the `nopy-cubes` version it wanted is not on npmjs. That is the guard working;
release `nopy-cubes`, then re-tag. `node scripts/publish-order.mjs` prints the
order if you would rather not reason about it.
Bumping `nopy-cube` means bumping the packages that depend on it in the same
Bumping `nopy-cubes` means bumping the packages that depend on it in the same
change — the `workspace:*` range resolves to whatever version is in the workspace
at pack time, so their next release picks it up whether or not you meant it to.
@@ -387,12 +387,12 @@ npm install -g @bitsquare/nopy @bitsquare/keyman
```
The other two go into a project. A cube bundle is a dev dependency of whatever
repo describes your infrastructure; `nopy-cube` is only needed if you are writing
repo describes your infrastructure; `nopy-cubes` is only needed if you are writing
cubes of your own:
```sh
pnpm add -D @bitsquare/cubes-core # then name it in .nopyrc.json cubePackages
pnpm add -D @bitsquare/nopy-cube zod # authoring your own manifests
pnpm add -D @bitsquare/nopy-cubes-core # then name it in .nopyrc.json cubePackages
pnpm add -D @bitsquare/nopy-cubes zod # authoring your own manifests
```
From the Gitea registry, which holds every snapshot plus a mirror of every
@@ -525,9 +525,9 @@ pnpm run try:snapshot -- --keep # keep the directory
`scripts/try-snapshot.mjs` builds a throwaway project in a temp directory,
points the `@bitsquare` scope at the registry, installs `@bitsquare/nopy` and
`@bitsquare/cubes-core` at that tag, and then:
`@bitsquare/nopy-cubes-core` at that tag, and then:
- asserts the installed `nopy` declares a **concrete** `nopy-cube` version
- asserts the installed `nopy` declares a **concrete** `nopy-cubes` version
rather than a leaked `workspace:*` range;
- prints the three resolved versions, so you can see which commit you are on;
- runs `nopy --version`;
@@ -662,13 +662,13 @@ Use **npm**, not pnpm: npm is the one that rejects a leaked `workspace:` range,
so a clean install here is the real proof.
```sh
pnpm --filter @bitsquare/nopy-cube pack --pack-destination /tmp/tgz
pnpm --filter @bitsquare/nopy-cubes pack --pack-destination /tmp/tgz
pnpm --filter @bitsquare/nopy pack --pack-destination /tmp/tgz
pnpm --filter @bitsquare/cubes-core pack --pack-destination /tmp/tgz
pnpm --filter @bitsquare/nopy-cubes-core pack --pack-destination /tmp/tgz
mkdir /tmp/try && cd /tmp/try && npm init -y
npm install /tmp/tgz/*.tgz
echo '{"hosts":["h"],"cubePackages":["@bitsquare/cubes-core"]}' > .nopyrc.json
echo '{"hosts":["h"],"cubePackages":["@bitsquare/nopy-cubes-core"]}' > .nopyrc.json
./node_modules/.bin/nopy install -l session.json -P -D
```
@@ -713,7 +713,7 @@ pnpm run registry:status
| Snapshot workflow green, nothing installable | Snapshots are only on Gitea and only under `@main`. Point the scope at the Gitea registry. |
| The release workflow did not trigger | The tag must match `*-v*` and must be pushed (`git push origin <tag>`), not just created. |
| `EUNSUPPORTEDPROTOCOL` / `Unsupported URL Type "workspace:"` on install | A `workspace:` range reached a tarball — something published with `npm publish` instead of `pnpm publish`. `verify-pack.mjs` exists to catch this before it ships. |
| `... is not published yet on npmjs` before the gate runs | Releasing a package before its `nopy-cube` dependency. Tag and release `nopy-cube` first, then re-tag. |
| `... is not published yet on npmjs` before the gate runs | Releasing a package before its `nopy-cubes` dependency. Tag and release `nopy-cubes` first, then re-tag. |
| `verify-pack.mjs` fails locally with a build error | `pnpm pack` runs `prepack`, so a broken build fails the check. Fix the build; there is no skip flag. |
## Recovering from a bad publish
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "The core nopy cube bundle: apt, users, ssh, networking, services and runtimes.",
"keywords": [
"nopy",
"nopy-cubess",
"nopy-cubes",
"pyinfra",
"deployment",
"infrastructure"
+14 -14
View File
@@ -17,9 +17,9 @@ importers:
'@biomejs/biome':
specifier: ^2.5.5
version: 2.5.5
'@bitsquare/cubes-core':
'@bitsquare/nopy-cubes-core':
specifier: workspace:*
version: link:packages/cubes-core
version: link:packages/nopy-cubes-core
'@logtape/logtape':
specifier: ^2.2.4
version: 2.2.4
@@ -33,15 +33,6 @@ importers:
specifier: ^7.0.2
version: 7.0.2
packages/cubes-core:
dependencies:
'@bitsquare/nopy-cube':
specifier: workspace:*
version: link:../nopy-cube
zod:
specifier: ^4.4.3
version: 4.4.3
packages/keyman:
dependencies:
execa:
@@ -78,9 +69,9 @@ importers:
packages/nopy:
dependencies:
'@bitsquare/nopy-cube':
'@bitsquare/nopy-cubes':
specifier: workspace:*
version: link:../nopy-cube
version: link:../nopy-cubes
'@logtape/logtape':
specifier: ^2.2.4
version: 2.2.4
@@ -128,7 +119,7 @@ importers:
specifier: ^4.1.10
version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1))
packages/nopy-cube:
packages/nopy-cubes:
devDependencies:
'@types/node':
specifier: ^26.1.1
@@ -146,6 +137,15 @@ importers:
specifier: ^4.4.3
version: 4.4.3
packages/nopy-cubes-core:
dependencies:
'@bitsquare/nopy-cubes':
specifier: workspace:*
version: link:../nopy-cubes
zod:
specifier: ^4.4.3
version: 4.4.3
packages:
'@babel/helper-string-parser@7.29.7':
+1 -1
View File
@@ -26,7 +26,7 @@ if (!target) {
const read = (dir) => JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
// Resolved by name rather than by directory: nothing guarantees that
// `@bitsquare/nopy-cube` lives in `packages/nopy-cube`.
// `@bitsquare/nopy-cubes` lives in `packages/nopy-cubes`.
const versionByName = new Map(
fs
.readdirSync(PACKAGES_DIR)
+1 -1
View File
@@ -3,7 +3,7 @@
/**
* Prints the publishable workspace package directories, dependencies first.
*
* `packages/*` in alphabetical order puts `nopy` ahead of the `nopy-cube` it
* `packages/*` in alphabetical order puts `nopy` ahead of the `nopy-cubes` it
* depends on, which leaves a window where the registry holds a package whose
* dependency does not exist yet. Ordering the publish by the workspace graph
* closes it. One directory per line, so the caller can `for dir in $(…)`.
+6 -6
View File
@@ -5,7 +5,7 @@
* project, and runs it.
*
* This is the rehearsal that the local `pnpm pack` check cannot be: it goes to
* the real registry, resolves the real `@bitsquare/nopy-cube` version that
* the real registry, resolves the real `@bitsquare/nopy-cubes` version that
* `pnpm publish` baked into the tarball, and puts a real `nopy` binary on disk.
* A tarball that installs here is one a user can install.
*
@@ -26,7 +26,7 @@ import path from 'node:path';
const SCOPE = '@bitsquare';
const DEFAULT_REGISTRY = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
const CLI_PACKAGE = '@bitsquare/nopy';
const BUNDLE_PACKAGE = '@bitsquare/cubes-core';
const BUNDLE_PACKAGE = '@bitsquare/nopy-cubes-core';
const args = process.argv.slice(2);
@@ -101,17 +101,17 @@ try {
// The whole point of packing with pnpm: this must be a concrete version, not
// the literal string `workspace:*`.
const linked = installed.dependencies?.['@bitsquare/nopy-cube'];
const linked = installed.dependencies?.['@bitsquare/nopy-cubes'];
if (!linked || linked.startsWith('workspace:')) {
throw new Error(
`${CLI_PACKAGE} declares nopy-cube as "${linked}" — a workspace range escaped.`
`${CLI_PACKAGE} declares nopy-cubes as "${linked}" — a workspace range escaped.`
);
}
console.log('\n--- versions ---');
console.log(`${CLI_PACKAGE}@${installed.version}`);
console.log(`${BUNDLE_PACKAGE}@${bundle.version}`);
console.log(` -> @bitsquare/nopy-cube ${linked}`);
console.log(` -> @bitsquare/nopy-cubes ${linked}`);
console.log('\n--- nopy --version ---');
console.log(capture(path.join(dir, 'node_modules', '.bin', 'nopy'), ['--version']));
@@ -139,7 +139,7 @@ try {
[
...discovery
.replace(ansi, '')
.matchAll(/([a-z0-9:_-]+) - [^\n]*\(@bitsquare\/cubes-core\)/g),
.matchAll(/([a-z0-9:_-]+) - [^\n]*\(@bitsquare\/nopy-cubes-core\)/g),
].map((match) => match[1])
),
];
+1 -1
View File
@@ -3,7 +3,7 @@
/**
* Asserts that every publishable package is installable once packed.
*
* `@bitsquare/nopy` depends on `@bitsquare/nopy-cube` through `workspace:*`, and
* `@bitsquare/nopy` depends on `@bitsquare/nopy-cubes` through `workspace:*`, and
* `link-workspace-packages` is off, so a plain semver range would resolve from
* the registry instead of linking the workspace copy — the protocol is not
* optional. But npm has no idea what `workspace:` means: a tarball that still
+1 -1
View File
@@ -13,7 +13,7 @@
"resolveJsonModule": true,
"paths": {
"@bitsquare/nopy": ["./packages/nopy/src"],
"@bitsquare/nopy-cube": ["./packages/nopy-cube/src"],
"@bitsquare/nopy-cubes": ["./packages/nopy-cubes/src"],
"@bitsquare/keyman": ["./packages/keyman/src"]
}
},
+1 -1
View File
@@ -2,7 +2,7 @@
"extends": "./tsconfig.base.json",
"files": [],
"references": [
{ "path": "./packages/nopy-cube" },
{ "path": "./packages/nopy-cubes" },
{ "path": "./packages/nopy" },
{ "path": "./packages/keyman" }
]