diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4246db5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write # create the GitHub Release + upload assets + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # goreleaser needs full history + tags for the changelog + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache: true + + - name: goreleaser release + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + # Used to create the release on this repo. Provided automatically. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # PAT with write access to civitai/homebrew-tap so goreleaser can push + # the updated formula. Set this repo secret (and create the tap repo) + # before tagging, or comment out the `brews:` block in .goreleaser.yaml. + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 68337f8..2a95218 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ /dist/ *.out *.test +coverage.out +coverage.html .DS_Store +.venv/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index aea4d33..9a6e5a0 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,6 +1,10 @@ # goreleaser config for the civitai CLI. # Build + release binaries and a Homebrew tap formula. # Docs: https://goreleaser.com +# +# Validate without releasing: +# goreleaser check +# goreleaser release --snapshot --clean # full dry-run into ./dist version: 2 project_name: civitai @@ -16,7 +20,10 @@ builds: env: - CGO_ENABLED=0 ldflags: - - -s -w -X main.version={{ .Version }} + - -s -w + - -X main.version={{ .Version }} + - -X main.commit={{ .Commit }} + - -X main.date={{ .Date }} goos: - linux - darwin @@ -32,6 +39,9 @@ archives: - id: civitai name_template: >- {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + files: + - README.md + - LICENSE format_overrides: - goos: windows formats: [zip] @@ -39,6 +49,9 @@ archives: checksum: name_template: "checksums.txt" +snapshot: + version_template: "{{ incpatch .Version }}-next" + changelog: sort: asc filters: @@ -47,20 +60,31 @@ changelog: - "^test:" - "^chore:" -# Homebrew tap. Point this at the org's tap repo when one exists. -brews: +# Homebrew tap (cask). goreleaser v2 replaced `brews:` with `homebrew_casks:`. +# NOTE: the `civitai/homebrew-tap` repo must exist and the release workflow must +# expose a HOMEBREW_TAP_GITHUB_TOKEN with write access to it. If you don't want +# a tap yet, comment out this whole block (and the brew install line in README). +homebrew_casks: - name: civitai + binaries: + - civitai repository: owner: civitai name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" homepage: "https://github.com/civitai/cli" description: "Civitai CLI — author and ship App Blocks" - license: "MIT" commit_author: name: civitai-bot email: bot@civitai.com - test: | - system "#{bin}/civitai", "--version" + # Strip the macOS quarantine attribute so `civitai` runs without a Gatekeeper + # prompt (the binaries are not notarized). + hooks: + post: + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/civitai"] + end release: draft: true diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6536ab6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,149 @@ +# CLAUDE.md — repo guide for the civitai CLI + +This file orients AI agents and human contributors working in this repo. It is +the source of truth for layout, conventions, and the release process. + +## What this is + +`civitai` is a single-binary Go CLI (in the `gh` / `kubectl` / `stripe` mold) +for [Civitai](https://civitai.com). Its first feature group is **App Blocks +authoring** under `civitai app`: it scaffolds a correct project, validates the +manifest against the platform contract, and packages/submits it for review. + +## Architecture + +``` +cmd/civitai/main.go binary entrypoint; injects build version/commit/date +internal/cmd/ the Cobra command tree (one file per command) + root.go root command + SetBuildInfo + subcommand wiring + app.go `civitai app` group + app_init.go `civitai app init` -> internal/scaffold + app_validate.go `civitai app validate` -> internal/validate + app_submit.go `civitai app submit` -> internal/{pkgzip,api} + login.go / whoami.go auth -> internal/{config,api} + version.go `civitai version` + completion.go `civitai completion` (Cobra built-in generators) +internal/scaffold/ embedded project templates (go:embed) + slug logic +internal/validate/ JSON-Schema + ported semantic checks (see fidelity caveat) +internal/pkgzip/ canonical source-tree ZIP packaging + server caps +internal/manifest/ the manifest filename + a thin reader +internal/api/ HTTP client (submit + whoami) behind interfaces +internal/config/ Viper-backed config (~/.config/civitai/config.yaml) +schema/ vendored App Block manifest JSON Schema (embedded) +examples/ real example manifests (validated by examples_test.go) +schema.go / main.go module-root package: go:embed of schema + examples +``` + +The module root (`package cli`, `main.go` + `schema.go`) exists only to embed +the vendored schema and the example manifests with `go:embed`. The executable +is `./cmd/civitai`. + +## Conventions + +- **Cobra + Viper.** Each command is a `newXxxCmd() *cobra.Command` constructor + in `internal/cmd`, added to the tree in `root.go`. Always set a clear + `Short`, a useful `Long`, and an `Example`. +- **Errors:** return `error` from `RunE`; lowercase, no trailing punctuation; + wrap with `%w` when the cause matters. The root sets `SilenceUsage` + + `SilenceErrors`, and `main` prints `Error: ...` to stderr with exit 1. Make + errors actionable (tell the user the next command to run). +- **Output:** write to `cmd.OutOrStdout()` / `cmd.ErrOrStderr()`, never bare + `fmt.Println`, so commands stay testable. +- **Testability:** network/disk seams sit behind small interfaces + (`api.Submitter`, `api.Verifier`) or take a dir argument, so tests use + `httptest` and `t.TempDir()` with no live server. +- **Config:** persisted at `~/.config/civitai/config.yaml` (0600, atomic + write). Overridable by `CIVITAI_*` env vars (`CIVITAI_TOKEN`, + `CIVITAI_BASE_URL`). The default base URL is `https://civitai.com`. + +## How to add a new command + +1. Create `internal/cmd/.go` with `func newCmd() *cobra.Command`. + Set `Use`, `Short`, `Long`, `Example`, `Args`, and `RunE`. +2. Register it in `root.go` (`root.AddCommand(newCmd())`), or under a + group constructor (e.g. `newAppCmd`) for a subcommand. +3. Write `internal/cmd/_test.go` covering the happy path and the error + paths. Drive it via `NewRootCmd()` + `SetArgs`, or call the constructor and + capture `SetOut`/`SetErr` buffers. +4. `make ci` must stay green; update `README.md` (command table + a section). + +## The manifest-schema-fidelity caveat (IMPORTANT) + +`civitai app validate` is a **best-effort LOCAL mirror** of the server-side +`BlockManifestValidator` +(`civitai/civitai → src/server/services/block-manifest-validator.service.ts`). +**The server is the source of truth** at review time. + +- `schema/app-block.manifest.schema.json` (embedded via `schema.go`) covers the + **syntactic** rules only (types, enums, ranges when a field is present). +- The **semantic** rules the JSON Schema cannot express — sandbox trust-tier + allowlist, `page` ⇒ `iframe`, required iframe sub-fields, the `renderMode` + tier gate, `targets[].slotId` registry membership — are **ported into Go** in + `internal/validate/{semantic.go,targets.go}`. +- A few checks are necessarily approximate locally (the slot registry is + **vendored** in `targets.go`; origin-binding and scope⊆client checks depend + on per-app server state the CLI can't see, so they are not reproduced). + +**The durable fix is a server-side `civitai app validate` endpoint** that calls +the real `BlockManifestValidator` — the faithful contract — with this schema +published as the syntactic half. Until that exists: on any change to a +validation rule, keep the **vendored schema** and the **ported Go checks** in +sync with the server validator, and update `examples_test.go` (which asserts the +shipped example manifests validate clean) + the README. + +## Build / test / lint + +```bash +make build # -> bin/civitai (with version ldflags from git describe) +make test # go test ./... +make vet # go vet ./... +make fmt # gofmt -s -w . +make lint # golangci-lint if installed, else go vet +make ci # tidy + vet + test + build (mirrors GitHub Actions CI) +``` + +Coverage: `go test ./... -cover` (per-package) or +`go test ./... -coverprofile=coverage.out && go tool cover -func=coverage.out`. + +CI (`.github/workflows/ci.yml`) runs `go vet`, `gofmt -s -l .`, `go test ./...`, +and `go build ./...` on every push to `main` and every PR. + +## Release process + +Releases are built and published by **goreleaser** from a GitHub Actions +workflow on a `v*` tag push. + +1. Make sure `main` is green and `CHANGELOG`-worthy commits are merged. +2. Tag and push: + ```bash + git tag v0.1.0 + git push origin v0.1.0 + ``` +3. `.github/workflows/release.yml` runs `goreleaser release`, which: + - cross-compiles for linux/darwin/windows × amd64/arm64 (no windows/arm64), + - stamps `version`/`commit`/`date` via `-ldflags` into `cmd/civitai`, + - produces archives (`.tar.gz`, `.zip` on Windows) + `checksums.txt`, + - creates the GitHub Release (currently `draft: true` — publish it manually + after sanity-checking the artifacts), + - bumps the Homebrew tap formula in `civitai/homebrew-tap`. + +Validate the config locally without releasing: + +```bash +goreleaser check # config is valid +goreleaser release --snapshot --clean # full dry-run build into ./dist +``` + +### Secrets the release workflow needs + +- `GITHUB_TOKEN` — provided automatically by Actions; used to create the + release on this repo. +- `HOMEBREW_TAP_GITHUB_TOKEN` — a PAT (or fine-grained token) with write + access to the **`civitai/homebrew-tap`** repo, so goreleaser can push the + updated formula. **The tap repo must exist** and the secret must be set, or + the `brews:` step fails. If you don't want a Homebrew tap yet, comment out + the `brews:` block in `.goreleaser.yaml`. + +## License + +Apache License 2.0 (`LICENSE`), matching the main `civitai/civitai` repo. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3b53c55 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing to the civitai CLI + +Thanks for your interest in improving the Civitai CLI! This is a small, +focused Go project; contributions of all sizes are welcome. + +## Getting set up + +You need **Go 1.25+**. Clone the repo and run: + +```bash +make ci # go mod tidy + go vet + go test ./... + go build ./... +``` + +Common targets: + +```bash +make build # build ./bin/civitai +make test # go test ./... +make vet # go vet ./... +make fmt # gofmt -s -w . +make lint # golangci-lint if installed, else go vet +``` + +## Before you open a PR + +Please make sure all of these pass — CI runs the same checks: + +```bash +go build ./... +go test ./... +go vet ./... +gofmt -s -l . # must print nothing +``` + +New behaviour should come with tests. Cover error paths, not just the happy +path — see the existing `*_test.go` files for the table-driven / httptest +patterns we use. + +## Architecture + +See [`CLAUDE.md`](CLAUDE.md) for the full layout, conventions, how to add a new +command, and the release process. The short version: + +- `cmd/civitai` — the binary entrypoint. +- `internal/cmd` — the Cobra command tree (one file per command). +- `internal/{scaffold,validate,pkgzip,manifest,api,config}` — the building blocks. +- `schema/` — the vendored App Block manifest JSON Schema. + +## The validate fidelity caveat + +`civitai app validate` is a **best-effort local mirror** of the server-side +`BlockManifestValidator`. The server is the source of truth. If you change a +validation rule, keep the vendored schema (`schema/`) and the ported Go checks +(`internal/validate`) in sync with the server validator, and update the docs. +See `CLAUDE.md` for details. + +## Commit / PR style + +- Keep PRs focused; describe what changed and why. +- Conventional-commit-style subjects are appreciated (`feat:`, `fix:`, + `docs:`, `test:`, `chore:`) — the changelog filters on them. + +## License + +By contributing you agree that your contributions are licensed under the +project's [Apache 2.0 license](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ec8a6d --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2026 Civitai + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 844e73b..bb7c0ca 100644 --- a/README.md +++ b/README.md @@ -1,245 +1,196 @@ # civitai CLI -The unified command-line interface for [Civitai](https://civitai.com) — a -single static binary in the `gh` / `kubectl` / `stripe` mold, with room to grow. +The command-line interface for [Civitai](https://civitai.com) — a single static +binary for authoring and shipping **App Blocks**. -Its first feature group is **App Blocks authoring** under `civitai app`. It -replaces the confusing "hand-format a ZIP" flow: the CLI **generates** the -correct project shape, **validates** the manifest against the platform contract, -and **packages/submits** it for review. +An **App Block** is a small, sandboxed web app that runs inside Civitai +surfaces (it's served in an iframe; the platform owns the build and the +runtime). The CLI replaces the error-prone "hand-format a ZIP" flow: it +**scaffolds** a correct project, **validates** the manifest against the platform +contract, and **packages/submits** it for review. + +> Learn more about App Blocks in the [Civitai App Blocks docs](https://civitai.com/articles) +> (search "App Blocks"). ## Install -### From source (Go 1.25+) +### Homebrew ```bash -go install github.com/civitai/cli/cmd/civitai@latest -# binary is installed as `civitai` +brew install civitai/tap/civitai ``` -### Build locally +> The tap (`civitai/homebrew-tap`) is published from this repo's release flow. +> Until the first tagged release lands, use one of the methods below. + +### Go install (Go 1.25+) ```bash -make build # -> bin/civitai -make install # -> $GOBIN/civitai +go install github.com/civitai/cli/cmd/civitai@latest +# installs the `civitai` binary into $(go env GOPATH)/bin ``` -### Homebrew +### Prebuilt binary -A [goreleaser](.goreleaser.yaml) config publishes binaries + a Homebrew tap -formula on tagged releases: +Download a prebuilt binary for your OS/arch from the +[GitHub Releases](https://github.com/civitai/cli/releases) page (linux, macOS, +windows × amd64/arm64), verify it against `checksums.txt`, then put it on your +`PATH`: ```bash -brew install civitai/tap/civitai # once the tap repo is published +tar xzf civitai_*_linux_amd64.tar.gz +sudo mv civitai /usr/local/bin/ +civitai version ``` -## What is an App Block? - -A block is a **sandboxed static web app** (an iframe serving static JS/HTML/CSS). -The platform owns the build (runs `npm ci` + your build command → static -output, or serves static as-is) and the runtime. The **only mandatory file is -`block.manifest.json`**. Server-owned fields (`iframe.src`, `trustTier`) are -assigned by the platform — your manifest must not set them. +## Quickstart -## Command surface +```bash +# 1. Authenticate once (create a token at https://civitai.com/user/account). +civitai login -``` -civitai -├── app -│ ├── init [name] [--template static|page-vite] [--from ] -│ ├── validate [dir] -│ └── submit [dir] [--package-only] [--out file.zip] [--skip-validate] -├── login [--token ] -└── whoami -``` +# 2. Scaffold a ready-to-build App Block. +civitai app init my-app --template page-vite +cd my-app -### `civitai app init` +# 3. Edit your app, then check the manifest before submitting. +civitai app validate -Scaffolds a correct, ready-to-build block project (templates are embedded in the -binary with `go:embed`): +# 4. Package + submit for review (or write the bundle + print next steps). +civitai app submit +``` -- **`static`** — a no-build page block (`index.html` + a tiny `app.js`, - `block.manifest.json` with `page:{}`, no build step). -- **`page-vite`** — a Vite + React page block with the **config-as-code** build - fields `buildCommand: "npm run build"` + `outputDir: "dist"`. +Enable shell completion (optional): ```bash -civitai app init my-block -civitai app init "My Cool Block" --template page-vite +source <(civitai completion bash) # bash; see `civitai completion --help` for zsh/fish/powershell ``` -`--from ` (fork-to-start) is **not yet wired** — it needs a server -endpoint that returns a published block's source by slug. It prints a clear -"not yet wired" message rather than faking it. +## Command reference -### `civitai app validate` +| Command | What it does | +| --- | --- | +| `civitai login [--token ]` | Store your API token (`~/.config/civitai/config.yaml`, 0600). Also reads `CIVITAI_TOKEN`. | +| `civitai whoami` | Verify the stored token; print the authenticated user. | +| `civitai app init [name] [--template static\|page-vite]` | Scaffold a correct, ready-to-build App Block project. | +| `civitai app validate [dir]` | Best-effort local pre-check of `block.manifest.json` (see [Validate fidelity](#validate-fidelity)). | +| `civitai app submit [dir] [--package-only] [--out f.zip] [--skip-validate]` | Validate + package the source tree + submit (or write the bundle + print manual next steps). | +| `civitai version` | Print version / commit / build date. | +| `civitai completion [shell]` | Generate a shell-completion script. | -A **best-effort LOCAL pre-check** that mirrors the platform's approve-time -validator (`BlockManifestValidator`). It catches most rejections before you -submit, but **the server remains the source of truth** — see -[Validate fidelity](#validate-fidelity) below. +Run `civitai help`, `civitai app --help`, or `civitai --help` for the +full details and examples. -It validates `block.manifest.json` against a **vendored JSON Schema** -([`schema/app-block.manifest.schema.json`](schema/app-block.manifest.schema.json), -syntactic shape) **plus the ported semantic rules** the server runs at approve -time and structural project checks: - -- manifest present at the project root; -- `buildCommand` + `outputDir` coherence, and `outputDir` is a safe **relative** - path (no leading `/`, no `..` traversal); -- server-owned `iframe.src` / `trustTier` rejected with a clear message; -- **sandbox** tokens limited to the unverified-tier allowlist - (`allow-scripts`, `allow-forms`); `allow-same-origin`+`allow-scripts` - (sandbox escape) rejected explicitly; -- a `page` manifest must declare an `iframe` block, and `renderMode:iframe` - (the default) requires one too; -- `iframe.minHeight` (40–4000) and `iframe.resizable` are **required** when an - iframe block is present; -- `renderMode` `inline`/`hybrid` rejected (they need a verified/internal trust - tier, which the platform only assigns post-submit — `INLINE_REQUIRES_VERIFIED_TIER`); -- `targets[].slotId` must be a **known registered slot** (and not the page slot). +### Templates -```bash -civitai app validate # current directory -civitai app validate ./my-block -``` +- **`static`** — a no-build page block (`index.html` + a tiny `app.js`, + `block.manifest.json` with `page:{}`, no build step). +- **`page-vite`** — a Vite + React page block with config-as-code build fields + (`buildCommand: "npm run build"` + `outputDir: "dist"`). -The two real example manifests under [`examples/`](examples/) (buzz-generator, -notepad — copied from the shipping `civitai-block-*` apps) validate clean; a -test asserts this so the claim stays true. +### Examples -#### Validate fidelity +Two real example manifests live under [`examples/`](examples/) (copied from the +shipping `civitai-block-*` apps) — a good reference for a correct manifest: -`validate` is a **local mirror** of the server validator, not the contract -itself. The vendored JSON Schema covers only **syntactic** rules; the server's -**semantic** rules (sandbox trust-tier allowlist, `page`⇒`iframe`, required -iframe sub-fields, `renderMode` tier gate, slot-registry membership) are **ported -into the Go `validate` layer** from -`block-manifest-validator.service.ts`. A few checks are necessarily -approximate locally: +- [`examples/buzz-generator.block.manifest.json`](examples/buzz-generator.block.manifest.json) +- [`examples/notepad.block.manifest.json`](examples/notepad.block.manifest.json) -- **`targets[].slotId`** is checked against a **vendored** copy of the slot - registry (only 4 ids today). If the server adds a slot, the vendored list must - be updated; until then a manifest using a brand-new slot would false-INVALID - locally (it still validates correctly server-side). -- Origin-binding (`iframe.src`/`assetBundleUrl` must be on an - `OauthClient.allowedOrigins`) and the scope⊆client check depend on - per-app server state the CLI can't see, so they are **not** reproduced. +Both validate clean (`examples_test.go` asserts this so the claim stays true). -The durable fix is a **server `civitai app validate` endpoint** that calls the -real `BlockManifestValidator` — the faithful contract. The server companion's -published manifest schema is a first step toward that. Until it exists, keep the -ported checks + vendored schema in sync with the server validator on each change. +## Validate fidelity -### `civitai app submit` +`civitai app validate` is a **best-effort LOCAL mirror** of the platform's +approve-time validator (`BlockManifestValidator`). **The server is the source of +truth** at review time — passing `validate` locally is a strong pre-check, not a +guarantee of approval. -Validates, then packages the **canonical source tree** (manifest + src + build -config — *not* a prebuilt `dist`) into a ZIP that matches the platform's build -recipe and server caps (50 MiB / 2000 files / 10 MiB per file). `.git`, -`node_modules`, and `dist` are excluded — the platform rebuilds from source. +It checks `block.manifest.json` against a **vendored JSON Schema** +([`schema/app-block.manifest.schema.json`](schema/app-block.manifest.schema.json), +syntactic shape) **plus the ported semantic rules** the server runs (sandbox +trust-tier allowlist, `page` ⇒ `iframe`, required iframe sub-fields, the +`renderMode` tier gate, `targets[].slotId` registry membership) and structural +project checks. A few checks are necessarily approximate locally (the slot +registry is vendored; per-app origin-binding/scope checks the CLI can't see are +not reproduced). + +The **durable fix** is a server-side `civitai app validate` endpoint that calls +the real `BlockManifestValidator` (the faithful contract), with this schema +published as the syntactic half. See [`CLAUDE.md`](CLAUDE.md) for the full +caveat and how the vendored schema + Go checks are kept in sync. -```bash -civitai app submit # validate + package + submit (or print next steps) -civitai app submit --package-only # just write the .zip -``` +## Submit & auth (current state) -See **[Submit & auth](#submit--auth-current-state)** for what's automated today. +The live upload route (`POST /api/blocks/submit-version`) is **session-cookie + +moderator** authenticated today — it does not accept an API token — so a fully +programmatic `civitai app submit` needs a companion token-accepting server +endpoint. Until that ships: -### `civitai login` / `civitai whoami` +- `civitai app submit` always **validates + packages** the canonical source ZIP. +- If a token *and* a token-accepting endpoint are configured (set + `CIVITAI_SUBMIT_PATH`), it uploads directly with `Authorization: Bearer`. +- Otherwise it **writes the `.zip` and prints the exact manual next steps** + (web upload at `/apps/submit`, or the git-push path for updates). -```bash -civitai login --token # stored in ~/.config/civitai/config.yaml (chmod 600) -civitai whoami # verify the token -``` +`--package-only` always just writes the `.zip` and stops. -Token can also be supplied via `CIVITAI_TOKEN`. Config keys are overridable by -`CIVITAI_*` env vars (`CIVITAI_BASE_URL`, etc.). - -## The vendored manifest schema - -[`schema/app-block.manifest.schema.json`](schema/app-block.manifest.schema.json) -is derived from the server-side validator -(`civitai/civitai → src/server/services/block-manifest-validator.service.ts`): -required fields, the scopes enum, page config (incl. positive-integer -`buzzBudgetPerGen`), sandbox tokens, the `contentRating` enum. It is embedded in -the binary (`go:embed`). The schema covers the **syntactic** rules only; the -server's **semantic** rules are ported into the Go `validate` layer (see -[Validate fidelity](#validate-fidelity)). - -> **The schema is one step toward a published shared contract — but it is not the -> whole contract.** The JSON Schema cannot express the cross-field / tier-gated -> semantic rules, so the CLI ports those into Go. The durable fix is a -> **server-side validate endpoint** that runs the real `BlockManifestValidator` -> (the faithful contract), with this schema published (e.g. at -> `https://civitai.com/schemas/app-block/v1.json`) as the syntactic half. Until -> then, keep both the vendored schema and the ported Go checks in sync with the -> server validator on each change. +## Configuration -## Submit & auth (current state) +| Setting | Config key | Env var | Default | +| --- | --- | --- | --- | +| API token | `token` | `CIVITAI_TOKEN` | — | +| API base URL | `base_url` | `CIVITAI_BASE_URL` | `https://civitai.com` | +| Submit endpoint | — | `CIVITAI_SUBMIT_PATH` | `/api/blocks/submit-version` | + +Config lives at `~/.config/civitai/config.yaml` (honours `XDG_CONFIG_HOME`), +written owner-readable only. + +## Troubleshooting -The submit/auth contract is the one **cross-repo dependency** in Phase 1. As of -the investigation (civitai/civitai @ `main`, 2026-06): - -- **The live upload route is `POST /api/blocks/submit-version`** and it is - **session-cookie + moderator** authenticated (`ModEndpoint`). It does **not** - accept an API key / bearer token. So a fully programmatic `civitai app submit` - is blocked on a companion server change. -- **The git-push flow** (`blocks.getMyAppRepo`, civitai #2587) provisions a - scoped Forgejo repo and a push parks a pending review — but it is itself - session-auth tRPC, and is only available **after the first version has been - ZIP-approved**. - -**What this CLI implements today:** - -1. It builds the canonical ZIP and **validates** it locally. -2. If a token *and* a token-accepting submit endpoint are configured - (`CIVITAI_SUBMIT_PATH`), it uploads directly with `Authorization: Bearer` - (this is exactly the payload shape the companion endpoint must accept — - base64 ZIP in `{ "bundleBase64": ... }`). -3. Otherwise it **writes the canonical `.zip` and prints the exact manual next - steps** (web upload at `/apps/submit`, or the git-push path for updates). - -The network/auth layer sits behind small interfaces (`api.Submitter`, -`api.Verifier`) so it is fully testable without a live server. - -### Server-side follow-up needed for a clean `submit` - -To make `civitai app submit` a one-command programmatic flow, the platform needs -a **token-authenticated** submit endpoint: - -- **Endpoint:** a sibling of `POST /api/blocks/submit-version` (or that route - extended) that accepts `Authorization: Bearer ` instead of a - session cookie. -- **Body:** the existing `submitVersionSchema` shape — `{ "bundleBase64": - "" }` (≤ ~72 MiB, the 50 MiB ZIP base64-encoded). -- **Authz:** resolve the API key → user, then apply the same gates the cookie - route applies (App Blocks flag; the moderator gate stays while App Blocks is - mod-gated; relax to "is app owner" when the feature widens). Reuse the - `submitVersion` service unchanged. -- **Response:** the publish-request `{ publishRequestId, slug, version, status }` - so the CLI can report it. - -Once that exists, set `CIVITAI_SUBMIT_PATH` to its path and `civitai app submit` -uploads end-to-end. +- **`no token configured`** — run `civitai login` (or set `CIVITAI_TOKEN`). +- **`unauthorized (401)`** — your token is invalid/expired; create a new one at + `https://civitai.com/user/account` and `civitai login` again. +- **`forbidden (403)` / `service unavailable (503)`** — your account may lack + App Blocks access while the feature is gated. +- **`validation failed`** — read each `- ...` line; fix the manifest, or pass + `--skip-validate` to package anyway (the server will still re-validate). +- **` is not empty — refusing to overwrite`** — `app init` won't clobber an + existing directory; pick a new name or remove the directory. ## Development ```bash -make ci # go mod tidy + vet + test + build +make ci # go mod tidy + vet + test + build (mirrors CI) make test -make vet +make build # -> bin/civitai make fmt +go test ./... -cover ``` - **Language:** Go 1.25, [Cobra](https://github.com/spf13/cobra) (commands) + [Viper](https://github.com/spf13/viper) (config). -- **Layout:** `cmd/civitai` (entrypoint) · `internal/cmd` (command tree) · - `internal/scaffold` (embedded templates) · `internal/validate` (schema + - structural checks) · `internal/pkgzip` (canonical packaging) · `internal/api` - (HTTP client) · `internal/config` (Viper) · `internal/manifest` · `schema/` - (vendored JSON Schema). -- **JSON Schema validation:** - [`santhosh-tekuri/jsonschema`](https://github.com/santhosh-tekuri/jsonschema). - -CI (`.github/workflows/ci.yml`) runs `go vet`, `gofmt -l`, `go test ./...`, and -`go build ./...` on every push/PR. +- **Layout / conventions / how to add a command / release process:** see + [`CLAUDE.md`](CLAUDE.md). +- **Contributing:** see [`CONTRIBUTING.md`](CONTRIBUTING.md). + +CI (`.github/workflows/ci.yml`) runs `go vet`, `gofmt -s -l .`, `go test ./...`, +and `go build ./...` on every push/PR. + +## Releasing + +Releases are built by [goreleaser](https://goreleaser.com) from a GitHub +Actions workflow on a `v*` tag push: + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` + +This cross-compiles for linux/darwin/windows × amd64/arm64, stamps +version/commit/date, and publishes a GitHub Release with archives + +`checksums.txt` plus a Homebrew tap bump. See [`CLAUDE.md`](CLAUDE.md) for the +full process and the secrets it needs (`HOMEBREW_TAP_GITHUB_TOKEN`). + +## License + +[Apache License 2.0](LICENSE). diff --git a/cmd/civitai/main.go b/cmd/civitai/main.go index f0b824a..236cb86 100644 --- a/cmd/civitai/main.go +++ b/cmd/civitai/main.go @@ -8,11 +8,17 @@ import ( "github.com/civitai/cli/internal/cmd" ) -// version is set at build time via -ldflags "-X main.version=...". -var version = "dev" +// Build metadata, injected at release time via goreleaser ldflags +// (see .goreleaser.yaml / .github/workflows/release.yml). They default to +// "dev"/"none"/"unknown" for `go install` / plain source builds. +var ( + version = "dev" + commit = "none" + date = "unknown" +) func main() { - cmd.SetVersion(version) + cmd.SetBuildInfo(version, commit, date) if err := cmd.NewRootCmd().Execute(); err != nil { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) diff --git a/internal/api/api_extra_test.go b/internal/api/api_extra_test.go new file mode 100644 index 0000000..8c4ed9c --- /dev/null +++ b/internal/api/api_extra_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNewDefaultsSubmitPath(t *testing.T) { + c := New("https://civitai.com/", "tok", "") + if c.SubmitPath != "/api/blocks/submit-version" { + t.Errorf("default SubmitPath = %q", c.SubmitPath) + } + if c.BaseURL != "https://civitai.com" { + t.Errorf("BaseURL should be trimmed of trailing slash: %q", c.BaseURL) + } +} + +func TestSubmitVersionStatusErrors(t *testing.T) { + cases := []struct { + status int + want string + }{ + {http.StatusUnauthorized, "unauthorized (401)"}, + {http.StatusForbidden, "forbidden (403)"}, + {http.StatusServiceUnavailable, "service unavailable (503)"}, + {http.StatusInternalServerError, "server returned 500"}, + } + for _, tc := range cases { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(`{"error":"boom"}`)) + })) + c := New(srv.URL, "tok", "") + _, err := c.SubmitVersion(context.Background(), []byte("z")) + srv.Close() + if err == nil { + t.Errorf("status %d: expected error", tc.status) + continue + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("status %d: error %q should contain %q", tc.status, err, tc.want) + } + } +} + +func TestSubmitVersionGarbageResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not json")) + })) + defer srv.Close() + c := New(srv.URL, "tok", "") + if _, err := c.SubmitVersion(context.Background(), []byte("z")); err == nil { + t.Fatal("expected error for non-JSON 200 response") + } +} + +func TestWhoAmIServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"bad token"}`)) + })) + defer srv.Close() + c := New(srv.URL, "tok", "") + _, err := c.WhoAmI(context.Background()) + if err == nil || !strings.Contains(err.Error(), "bad token") { + t.Errorf("WhoAmI error = %v, want server message", err) + } +} + +func TestWhoAmIGarbageResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("<>")) + })) + defer srv.Close() + c := New(srv.URL, "tok", "") + if _, err := c.WhoAmI(context.Background()); err == nil { + t.Fatal("expected error for non-JSON identity response") + } +} + +func TestWhoAmISuccessReportsThroughCmd(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"username":"alice","id":7}`)) + })) + defer srv.Close() + c := New(srv.URL, "tok", "") + id, err := c.WhoAmI(context.Background()) + if err != nil { + t.Fatalf("WhoAmI: %v", err) + } + if id.Username != "alice" || id.ID != 7 { + t.Errorf("identity = %+v", id) + } +} diff --git a/internal/cmd/app.go b/internal/cmd/app.go index fec115a..6c09ade 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -11,10 +11,10 @@ func newAppCmd() *cobra.Command { An App Block is a sandboxed static web app served in an iframe. The platform owns the build and the runtime; the only mandatory file is block.manifest.json. - - civitai app init my-block --template page-vite - civitai app validate - civitai app submit`, +The typical lifecycle is init -> validate -> submit.`, + Example: ` civitai app init my-block --template page-vite + civitai app validate ./my-block + civitai app submit ./my-block`, } cmd.AddCommand(newAppInitCmd()) cmd.AddCommand(newAppValidateCmd()) diff --git a/internal/cmd/app_init.go b/internal/cmd/app_init.go index a5972e8..708a145 100644 --- a/internal/cmd/app_init.go +++ b/internal/cmd/app_init.go @@ -16,16 +16,20 @@ func newAppInitCmd() *cobra.Command { cmd := &cobra.Command{ Use: "init [name]", Short: "Scaffold a ready-to-build App Block project", - Long: `Scaffold a correct, ready-to-build App Block project. + Long: `Scaffold a correct, ready-to-build App Block project in a new directory +named after the slug. Templates: static a no-build page block (index.html + a tiny JS, no build step) page-vite a vite + React page block (config-as-code build: buildCommand + outputDir) -Examples: +The display name can be free-form ("My Cool Block"); it is slugified for the +blockId and directory. A slug-shaped name is used verbatim.`, + Example: ` # A no-build static block. civitai app init my-block - civitai app init "My Cool Block" --template page-vite - civitai app init forked --from some-published-slug`, + + # A Vite + React block; "My Cool Block" -> slug my-cool-block. + civitai app init "My Cool Block" --template page-vite`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { out := cmd.OutOrStdout() diff --git a/internal/cmd/app_submit.go b/internal/cmd/app_submit.go index 8b4b0a7..0886c3d 100644 --- a/internal/cmd/app_submit.go +++ b/internal/cmd/app_submit.go @@ -40,6 +40,9 @@ Submission path: --package-only always just writes the .zip and stops. Defaults to the current directory.`, + Example: ` civitai app submit # validate + package + submit (or print next steps) + civitai app submit --package-only # just write the .zip + civitai app submit -o my-block.zip ./my-block`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { dir := "." diff --git a/internal/cmd/app_submit_cmd_test.go b/internal/cmd/app_submit_cmd_test.go new file mode 100644 index 0000000..4e7db00 --- /dev/null +++ b/internal/cmd/app_submit_cmd_test.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeStaticManifest writes a minimal valid static page manifest into dir. +func writeStaticManifest(t *testing.T, dir string) { + t.Helper() + m := `{ + "$schema": "https://civitai.com/schemas/app-block/v1.json", + "blockId": "demo-block", + "version": "0.1.0", + "name": "Demo Block", + "type": "block", + "scopes": [], + "page": { "path": "/", "title": "Demo Block", "icon": "bolt" }, + "iframe": { "minHeight": 400, "maxHeight": 4000, "resizable": true, "sandbox": "allow-scripts allow-forms" }, + "contentRating": "g", + "minApiVersion": "1.0" +}` + if err := os.WriteFile(filepath.Join(dir, "block.manifest.json"), []byte(m), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestAppSubmitPackageOnly(t *testing.T) { + tmp := t.TempDir() + writeStaticManifest(t, tmp) + if err := os.WriteFile(filepath.Join(tmp, "index.html"), []byte(""), 0o600); err != nil { + t.Fatal(err) + } + out := filepath.Join(tmp, "bundle.zip") + + stdout, _, err := run(t, "app", "submit", tmp, "--package-only", "--out", out) + if err != nil { + t.Fatalf("submit --package-only: %v\n%s", err, stdout) + } + if _, err := os.Stat(out); err != nil { + t.Errorf("zip should be written: %v", err) + } + if !strings.Contains(stdout, "Wrote canonical bundle") { + t.Errorf("output should report the bundle: %s", stdout) + } +} + +func TestAppSubmitFallbackPrintsManualSteps(t *testing.T) { + tmp := t.TempDir() + writeStaticManifest(t, tmp) + + // No token + no submit path => fallback to writing zip + manual steps. + cfgdir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfgdir) + t.Setenv("CIVITAI_TOKEN", "") + t.Setenv("CIVITAI_SUBMIT_PATH", "") + + chdir(t, tmp) + stdout, _, err := run(t, "app", "submit") + if err != nil { + t.Fatalf("submit fallback: %v\n%s", err, stdout) + } + if !strings.Contains(stdout, "not yet automated") || !strings.Contains(stdout, "/apps/submit") { + t.Errorf("fallback should print manual next steps: %s", stdout) + } +} + +func TestAppSubmitRefusesInvalidManifest(t *testing.T) { + tmp := t.TempDir() + if err := os.WriteFile(filepath.Join(tmp, "block.manifest.json"), []byte(`{"blockId":"x"}`), 0o600); err != nil { + t.Fatal(err) + } + _, errOut, err := run(t, "app", "submit", tmp) + if err == nil { + t.Fatal("expected submit to fail on an invalid manifest") + } + if !strings.Contains(errOut, "validation failed") { + t.Errorf("stderr should mention validation: %s", errOut) + } +} + +func TestAppSubmitUploadsWhenTokenAndPathConfigured(t *testing.T) { + tmp := t.TempDir() + writeStaticManifest(t, tmp) + + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "publishRequestId": "pr_42", + "slug": "demo-block", + "version": "0.1.0", + "status": "pending", + }) + })) + defer srv.Close() + + cfgdir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfgdir) + t.Setenv("CIVITAI_TOKEN", "tok-xyz") + t.Setenv("CIVITAI_BASE_URL", srv.URL) + t.Setenv("CIVITAI_SUBMIT_PATH", "/api/blocks/submit-version") + + stdout, _, err := run(t, "app", "submit", tmp) + if err != nil { + t.Fatalf("submit upload: %v\n%s", err, stdout) + } + if gotAuth != "Bearer tok-xyz" { + t.Errorf("server saw auth %q, want Bearer tok-xyz", gotAuth) + } + if !strings.Contains(stdout, "pr_42") || !strings.Contains(stdout, "pending") { + t.Errorf("output should report the publish request: %s", stdout) + } +} + +func TestAppSubmitSkipValidatePackagesAnyway(t *testing.T) { + tmp := t.TempDir() + // Invalid manifest (missing required fields) but parseable JSON. + if err := os.WriteFile(filepath.Join(tmp, "block.manifest.json"), + []byte(`{"blockId":"x","version":"0.1.0"}`), 0o600); err != nil { + t.Fatal(err) + } + out := filepath.Join(tmp, "out.zip") + if _, _, err := run(t, "app", "submit", tmp, "--package-only", "--skip-validate", "--out", out); err != nil { + t.Fatalf("submit --skip-validate: %v", err) + } + if _, err := os.Stat(out); err != nil { + t.Errorf("zip should be written with --skip-validate: %v", err) + } +} diff --git a/internal/cmd/app_validate.go b/internal/cmd/app_validate.go index 728adfc..487185b 100644 --- a/internal/cmd/app_validate.go +++ b/internal/cmd/app_validate.go @@ -32,6 +32,8 @@ plus the ported semantic rules and structural checks: - targets[].slotId must be a known registered slot Defaults to the current directory.`, + Example: ` civitai app validate # the current directory + civitai app validate ./my-block`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { dir := "." diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go new file mode 100644 index 0000000..b5cc502 --- /dev/null +++ b/internal/cmd/cmd_test.go @@ -0,0 +1,280 @@ +package cmd + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// run executes the root command with args, capturing stdout+stderr. +func run(t *testing.T, args ...string) (string, string, error) { + t.Helper() + root := NewRootCmd() + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + root.SetArgs(args) + err := root.Execute() + return out.String(), errb.String(), err +} + +func TestRootHelpListsCommands(t *testing.T) { + out, _, err := run(t, "--help") + if err != nil { + t.Fatalf("--help: %v", err) + } + for _, want := range []string{"app", "login", "whoami", "version", "completion"} { + if !strings.Contains(out, want) { + t.Errorf("root help missing command %q\n%s", want, out) + } + } +} + +func TestVersionCommand(t *testing.T) { + out, _, err := run(t, "version") + if err != nil { + t.Fatalf("version: %v", err) + } + for _, want := range []string{"civitai", "commit:", "built:", "go:"} { + if !strings.Contains(out, want) { + t.Errorf("version output missing %q: %s", want, out) + } + } +} + +func TestSetBuildInfo(t *testing.T) { + origV, origC, origD := version, commit, date + t.Cleanup(func() { version, commit, date = origV, origC, origD }) + + SetBuildInfo("1.2.3", "abc123", "2026-01-01") + if version != "1.2.3" || commit != "abc123" || date != "2026-01-01" { + t.Fatalf("SetBuildInfo did not apply: %s %s %s", version, commit, date) + } + // Empty values must not clobber existing. + SetBuildInfo("", "", "") + if version != "1.2.3" || commit != "abc123" || date != "2026-01-01" { + t.Errorf("empty SetBuildInfo clobbered values: %s %s %s", version, commit, date) + } +} + +func TestCompletionGeneratesForEachShell(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "powershell"} { + out, _, err := run(t, "completion", shell) + if err != nil { + t.Fatalf("completion %s: %v", shell, err) + } + if len(out) == 0 { + t.Errorf("completion %s produced no output", shell) + } + } +} + +func TestCompletionRejectsUnknownShell(t *testing.T) { + if _, _, err := run(t, "completion", "tcsh"); err == nil { + t.Fatal("expected error for unknown shell") + } +} + +func TestAppHelp(t *testing.T) { + out, _, err := run(t, "app", "--help") + if err != nil { + t.Fatalf("app --help: %v", err) + } + for _, want := range []string{"init", "validate", "submit"} { + if !strings.Contains(out, want) { + t.Errorf("app help missing subcommand %q", want) + } + } +} + +func TestAppInitScaffoldsAndValidates(t *testing.T) { + tmp := t.TempDir() + chdir(t, tmp) + + out, _, err := run(t, "app", "init", "my-block") + if err != nil { + t.Fatalf("app init: %v\n%s", err, out) + } + if !strings.Contains(out, "Created App Block") { + t.Errorf("unexpected init output: %s", out) + } + // Manifest must exist and validate clean. + if _, err := os.Stat(filepath.Join(tmp, "my-block", "block.manifest.json")); err != nil { + t.Fatalf("scaffolded manifest missing: %v", err) + } + if _, _, err := run(t, "app", "validate", filepath.Join(tmp, "my-block")); err != nil { + t.Errorf("scaffolded project should validate: %v", err) + } +} + +func TestAppInitPageViteTemplate(t *testing.T) { + tmp := t.TempDir() + chdir(t, tmp) + out, _, err := run(t, "app", "init", "vite-app", "--template", "page-vite") + if err != nil { + t.Fatalf("app init page-vite: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(tmp, "vite-app", "package.json")); err != nil { + t.Errorf("page-vite should scaffold package.json: %v", err) + } + if !strings.Contains(out, "npm install") { + t.Errorf("page-vite next steps should mention npm install: %s", out) + } +} + +func TestAppInitRequiresName(t *testing.T) { + tmp := t.TempDir() + chdir(t, tmp) + if _, _, err := run(t, "app", "init"); err == nil { + t.Fatal("expected error when no name is given") + } +} + +func TestAppInitFromIsNotWired(t *testing.T) { + tmp := t.TempDir() + chdir(t, tmp) + _, _, err := run(t, "app", "init", "forked", "--from", "some-slug") + if err == nil { + t.Fatal("expected --from to be reported as not wired") + } + if !strings.Contains(err.Error(), "not yet wired") { + t.Errorf("error should say not yet wired: %v", err) + } +} + +func TestAppInitUnknownTemplate(t *testing.T) { + tmp := t.TempDir() + chdir(t, tmp) + if _, _, err := run(t, "app", "init", "x", "--template", "nope"); err == nil { + t.Fatal("expected error for unknown template") + } +} + +func TestAppInitSlugifiesDisplayName(t *testing.T) { + tmp := t.TempDir() + chdir(t, tmp) + out, _, err := run(t, "app", "init", "My Cool Block") + if err != nil { + t.Fatalf("app init: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(tmp, "my-cool-block", "block.manifest.json")); err != nil { + t.Errorf("expected slug dir my-cool-block: %v", err) + } +} + +func TestAppValidateReportsErrors(t *testing.T) { + tmp := t.TempDir() + // A manifest missing required fields. + if err := os.WriteFile(filepath.Join(tmp, "block.manifest.json"), []byte(`{"blockId":"x"}`), 0o600); err != nil { + t.Fatal(err) + } + _, errOut, err := run(t, "app", "validate", tmp) + if err == nil { + t.Fatal("expected validation to fail for a bad manifest") + } + if !strings.Contains(errOut, "validation error") { + t.Errorf("stderr should list validation errors: %s", errOut) + } +} + +func TestAppValidateMissingManifest(t *testing.T) { + tmp := t.TempDir() + _, _, err := run(t, "app", "validate", tmp) + if err == nil { + t.Fatal("expected error for missing manifest") + } +} + +func TestWhoAmIWithoutToken(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("CIVITAI_TOKEN", "") + _, _, err := run(t, "whoami") + if err == nil { + t.Fatal("expected error when no token configured") + } + if !strings.Contains(err.Error(), "no token") { + t.Errorf("error should mention missing token: %v", err) + } +} + +func TestWhoAmISuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok-1" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"username":"bob","id":99}`)) + })) + defer srv.Close() + + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("CIVITAI_TOKEN", "tok-1") + t.Setenv("CIVITAI_BASE_URL", srv.URL) + + out, _, err := run(t, "whoami") + if err != nil { + t.Fatalf("whoami: %v", err) + } + if !strings.Contains(out, "bob") || !strings.Contains(out, "99") { + t.Errorf("whoami output should report the user: %s", out) + } +} + +func TestLoginStoresToken(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("CIVITAI_TOKEN", "") + out, _, err := run(t, "login", "--token", "abc123") + if err != nil { + t.Fatalf("login: %v", err) + } + if !strings.Contains(out, "Token saved") { + t.Errorf("login should confirm save: %s", out) + } + if _, err := os.Stat(filepath.Join(dir, "civitai", "config.yaml")); err != nil { + t.Errorf("config file should be written: %v", err) + } +} + +func TestLoginEmptyTokenViaStdinFails(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("CIVITAI_TOKEN", "") + root := NewRootCmd() + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + root.SetIn(strings.NewReader("\n")) // empty line at the prompt + root.SetArgs([]string{"login"}) + if err := root.Execute(); err == nil { + t.Fatal("expected error for empty token") + } +} + +func TestJoinLines(t *testing.T) { + if got := joinLines([]string{"a", "b"}); got != "a\n b" { + t.Errorf("joinLines = %q", got) + } + if got := joinLines(nil); got != "" { + t.Errorf("joinLines(nil) = %q", got) + } +} + +// chdir changes into dir for the duration of the test. +func chdir(t *testing.T, dir string) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) +} diff --git a/internal/cmd/completion.go b/internal/cmd/completion.go new file mode 100644 index 0000000..1117e2c --- /dev/null +++ b/internal/cmd/completion.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// newCompletionCmd exposes Cobra's built-in shell-completion generator with a +// helpful, newcomer-friendly Long describing how to install it per shell. +func newCompletionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate a shell-completion script", + Long: `Generate a shell-completion script for the civitai CLI. + +Load completions for your current session, or install them permanently: + +Bash: + # current shell + source <(civitai completion bash) + # permanent (Linux) + civitai completion bash > /etc/bash_completion.d/civitai + +Zsh: + # ensure completion is enabled: echo "autoload -U compinit; compinit" >> ~/.zshrc + civitai completion zsh > "${fpath[1]}/_civitai" + +Fish: + civitai completion fish > ~/.config/fish/completions/civitai.fish + +PowerShell: + civitai completion powershell | Out-String | Invoke-Expression`, + Example: ` source <(civitai completion bash) + civitai completion zsh > "${fpath[1]}/_civitai"`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletionV2(out, true) + case "zsh": + return cmd.Root().GenZshCompletion(out) + case "fish": + return cmd.Root().GenFishCompletion(out, true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(out) + } + return nil + }, + } + return cmd +} diff --git a/internal/cmd/login.go b/internal/cmd/login.go index cb37793..2437180 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -19,9 +19,8 @@ func newLoginCmd() *cobra.Command { Create a token at https://civitai.com/user/account (API Keys). The token is saved to your config file (~/.config/civitai/config.yaml, owner-readable only) -and can also be supplied via the CIVITAI_TOKEN environment variable. - - civitai login --token +and can also be supplied via the CIVITAI_TOKEN environment variable.`, + Example: ` civitai login --token civitai login # prompts for the token`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bc16110..189f06a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -5,33 +5,51 @@ import ( "github.com/spf13/cobra" ) -// version is overridden at build time via -ldflags. -var version = "dev" +// Build metadata, overridden at build time via -ldflags (see cmd/civitai/main.go +// and .goreleaser.yaml). They default to dev values for source builds. +var ( + version = "dev" + commit = "none" + date = "unknown" +) -// SetVersion lets main inject the build version. -func SetVersion(v string) { +// SetBuildInfo lets main inject the build version, commit, and date. +func SetBuildInfo(v, c, d string) { if v != "" { version = v } + if c != "" { + commit = c + } + if d != "" { + date = d + } } // NewRootCmd builds the root command with all subcommands attached. func NewRootCmd() *cobra.Command { root := &cobra.Command{ Use: "civitai", - Short: "Civitai CLI — author and ship App Blocks (and more)", - Long: `civitai is the unified command-line interface for Civitai. - -Its first feature group is App Blocks authoring: + Short: "Civitai CLI — author and ship App Blocks", + Long: `civitai is the command-line interface for Civitai (https://civitai.com). - civitai app init my-block scaffold a ready-to-build block project - civitai app validate validate block.manifest.json - civitai app submit package + submit for review +Its first feature group is App Blocks authoring — App Blocks are small, +sandboxed web apps that run inside Civitai surfaces. The CLI scaffolds a +correct project, validates it against the platform contract, and packages it +for submission, so you don't have to hand-format a ZIP. -Authenticate once: +Get started: civitai login store your API token - civitai whoami verify your token`, + civitai app init my-app scaffold a ready-to-build App Block + civitai app validate check the manifest before you submit + civitai app submit package + submit for review`, + Example: ` # First time: authenticate, then scaffold and submit an app. + civitai login + civitai app init my-first-app --template page-vite + cd my-first-app + civitai app validate + civitai app submit`, SilenceUsage: true, SilenceErrors: true, Version: version, @@ -41,6 +59,8 @@ Authenticate once: root.AddCommand(newAppCmd()) root.AddCommand(newLoginCmd()) root.AddCommand(newWhoAmICmd()) + root.AddCommand(newVersionCmd()) + root.AddCommand(newCompletionCmd()) return root } diff --git a/internal/cmd/version.go b/internal/cmd/version.go new file mode 100644 index 0000000..00c9cc0 --- /dev/null +++ b/internal/cmd/version.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "fmt" + "runtime" + + "github.com/spf13/cobra" +) + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the CLI version, commit, and build date", + Long: `Print detailed build information for this civitai binary. + +The version, commit, and date are stamped in at release time. For a plain +"go install" or source build they read "dev" / "none" / "unknown".`, + Example: ` civitai version`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + fmt.Fprintf(out, "civitai %s\n", version) + fmt.Fprintf(out, " commit: %s\n", commit) + fmt.Fprintf(out, " built: %s\n", date) + fmt.Fprintf(out, " go: %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) + return nil + }, + } +} diff --git a/internal/cmd/whoami.go b/internal/cmd/whoami.go index f9aa9f3..eda7abb 100644 --- a/internal/cmd/whoami.go +++ b/internal/cmd/whoami.go @@ -15,7 +15,8 @@ func newWhoAmICmd() *cobra.Command { Short: "Verify your stored API token", Long: `Verify the stored API token by calling the Civitai API and printing the authenticated username. Reads the token from config or CIVITAI_TOKEN.`, - Args: cobra.NoArgs, + Example: ` civitai whoami`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5db88bc..6f05e12 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,8 @@ package config import ( "os" + "path/filepath" + "runtime" "testing" ) @@ -55,6 +57,60 @@ func TestSetTokenPersists(t *testing.T) { } } +func TestLoadMalformedConfigErrors(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + os.Unsetenv("CIVITAI_TOKEN") + + cfgDir := filepath.Join(dir, "civitai") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + // Not valid YAML. + if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte("::: not: yaml: ["), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(); err == nil { + t.Fatal("expected a parse error for a malformed config file") + } +} + +func TestDirHonoursXDG(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + got, err := Dir() + if err != nil { + t.Fatalf("Dir: %v", err) + } + if got != filepath.Join(dir, "civitai") { + t.Errorf("Dir = %q, want %q", got, filepath.Join(dir, "civitai")) + } +} + +func TestSaveErrorOnUnwritableDir(t *testing.T) { + if runtime.GOOS == "windows" || os.Getuid() == 0 { + t.Skip("permission-based test unreliable as root / on windows") + } + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + os.Unsetenv("CIVITAI_TOKEN") + + // Make the config parent dir unwritable so MkdirAll/CreateTemp fails. + cfgParent := filepath.Join(dir, "civitai") + if err := os.MkdirAll(cfgParent, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(cfgParent, 0o700) }) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if err := cfg.SetToken("x"); err == nil { + t.Error("expected SetToken to fail writing into an unwritable dir") + } +} + func TestSetBaseURL(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go new file mode 100644 index 0000000..af7709f --- /dev/null +++ b/internal/manifest/manifest_test.go @@ -0,0 +1,102 @@ +package manifest + +import ( + "os" + "path/filepath" + "testing" +) + +func write(t *testing.T, dir, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, Filename), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestPath(t *testing.T) { + if got := Path("foo"); got != filepath.Join("foo", Filename) { + t.Errorf("Path = %q", got) + } +} + +func TestLoadReadsFields(t *testing.T) { + dir := t.TempDir() + write(t, dir, `{ + "blockId": "demo", + "version": "1.2.3", + "name": "Demo", + "buildCommand": "npm run build", + "outputDir": "dist" + }`) + m, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if m.BlockID != "demo" || m.Version != "1.2.3" || m.Name != "Demo" { + t.Errorf("unexpected manifest: %+v", m) + } + if m.BuildCommand != "npm run build" || m.OutputDir != "dist" { + t.Errorf("build fields wrong: %+v", m) + } +} + +func TestLoadMissingFile(t *testing.T) { + _, err := Load(t.TempDir()) + if err == nil { + t.Fatal("expected error for missing manifest") + } + if !contains(err.Error(), "civitai app init") { + t.Errorf("error should hint at init: %v", err) + } +} + +func TestLoadInvalidJSON(t *testing.T) { + dir := t.TempDir() + write(t, dir, `{not json`) + if _, err := Load(dir); err == nil { + t.Fatal("expected JSON parse error") + } +} + +func TestLoadRaw(t *testing.T) { + dir := t.TempDir() + write(t, dir, `{"blockId":"x","version":"0.1.0","extra":true}`) + generic, m, err := LoadRaw(dir) + if err != nil { + t.Fatalf("LoadRaw: %v", err) + } + if m.BlockID != "x" { + t.Errorf("struct blockId = %q", m.BlockID) + } + gm, ok := generic.(map[string]any) + if !ok { + t.Fatalf("generic is %T, want map", generic) + } + if gm["extra"] != true { + t.Errorf("generic should preserve unknown fields: %v", gm) + } +} + +func TestLoadRawMissingAndInvalid(t *testing.T) { + if _, _, err := LoadRaw(t.TempDir()); err == nil { + t.Error("expected error for missing manifest") + } + dir := t.TempDir() + write(t, dir, `nope`) + if _, _, err := LoadRaw(dir); err == nil { + t.Error("expected error for invalid JSON") + } +} + +func contains(s, sub string) bool { + return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/pkgzip/pkgzip_extra_test.go b/internal/pkgzip/pkgzip_extra_test.go new file mode 100644 index 0000000..dcbdbd3 --- /dev/null +++ b/internal/pkgzip/pkgzip_extra_test.go @@ -0,0 +1,86 @@ +package pkgzip + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExcludedNamesAndJoin(t *testing.T) { + names := ExcludedNames() + if len(names) != 3 { + t.Fatalf("ExcludedNames = %v, want 3 entries", names) + } + // Sorted. + for i := 1; i < len(names); i++ { + if names[i-1] > names[i] { + t.Errorf("ExcludedNames not sorted: %v", names) + } + } + if JoinExcluded() == "" { + t.Error("JoinExcluded should be non-empty") + } +} + +func TestBuildMissingManifest(t *testing.T) { + if _, err := Build(t.TempDir()); err == nil { + t.Fatal("expected error when no manifest present") + } +} + +func TestBuildOnlyManifestStillPackages(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "block.manifest.json", `{"blockId":"x"}`) + res, err := Build(dir) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(res.Files) != 1 || res.Files[0] != "block.manifest.json" { + t.Errorf("files = %v", res.Files) + } + if res.DecompressedBy <= 0 { + t.Errorf("DecompressedBy = %d, want > 0", res.DecompressedBy) + } +} + +func TestBuildRejectsTooManyFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "block.manifest.json", `{"blockId":"x"}`) + for i := 0; i < MaxFiles+1; i++ { + writeFile(t, dir, filepath.Join("many", "f"+itoa(i)+".txt"), "x") + } + if _, err := Build(dir); err == nil { + t.Fatal("expected error when file count exceeds the server cap") + } +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + var b []byte + for i > 0 { + b = append([]byte{byte('0' + i%10)}, b...) + i /= 10 + } + return string(b) +} + +func TestBuildSkipsSymlinks(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "block.manifest.json", `{"blockId":"x"}`) + writeFile(t, dir, "real.txt", "hi") + link := filepath.Join(dir, "link.txt") + if err := os.Symlink(filepath.Join(dir, "real.txt"), link); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + res, err := Build(dir) + if err != nil { + t.Fatalf("Build: %v", err) + } + for _, f := range res.Files { + if f == "link.txt" { + t.Errorf("symlink should be skipped, got %v", res.Files) + } + } +} diff --git a/internal/scaffold/render_test.go b/internal/scaffold/render_test.go new file mode 100644 index 0000000..4eb9178 --- /dev/null +++ b/internal/scaffold/render_test.go @@ -0,0 +1,38 @@ +package scaffold + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRenderRefusesFileAsDir(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "afile") + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Render(Static, f, Data{Slug: "abc", Name: "Abc"}); err == nil { + t.Fatal("expected error rendering into a path that is a file") + } +} + +func TestRenderCreatesNestedDir(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "a", "b", "block") + if _, err := Render(Static, dest, Data{Slug: "block", Name: "Block"}); err != nil { + t.Fatalf("Render into nested non-existent dir: %v", err) + } + if _, err := os.Stat(filepath.Join(dest, "block.manifest.json")); err != nil { + t.Errorf("manifest not written: %v", err) + } +} + +func TestOutputNameMapsGitignore(t *testing.T) { + if got := outputName("gitignore.tmpl"); got != ".gitignore" { + t.Errorf("outputName(gitignore.tmpl) = %q", got) + } + if got := outputName("src/App.jsx.tmpl"); got != filepath.FromSlash("src/App.jsx") { + t.Errorf("outputName = %q", got) + } +} diff --git a/internal/scaffold/slug_test.go b/internal/scaffold/slug_test.go new file mode 100644 index 0000000..983a45f --- /dev/null +++ b/internal/scaffold/slug_test.go @@ -0,0 +1,43 @@ +package scaffold + +import "testing" + +func TestTitleFromSlug(t *testing.T) { + cases := map[string]string{ + "my-cool-block": "My Cool Block", + "notepad": "Notepad", + "a-b-c": "A B C", + } + for in, want := range cases { + if got := TitleFromSlug(in); got != want { + t.Errorf("TitleFromSlug(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSlugifyTooLongTrims(t *testing.T) { + long := "this-is-a-really-long-name-that-exceeds-the-forty-char-limit" + got, err := Slugify(long) + if err != nil { + t.Fatalf("Slugify: %v", err) + } + if len(got) > 40 { + t.Errorf("slug %q exceeds 40 chars (%d)", got, len(got)) + } + if err := ValidateSlug(got); err != nil { + t.Errorf("trimmed slug %q should be valid: %v", got, err) + } +} + +func TestSlugifyAllPunctuationFails(t *testing.T) { + if _, err := Slugify("!!!"); err == nil { + t.Error("expected error slugifying pure punctuation") + } +} + +func TestSlugifyAllTemplates(t *testing.T) { + ts := AllTemplates() + if len(ts) != 2 { + t.Errorf("AllTemplates = %v, want 2", ts) + } +} diff --git a/internal/validate/semantic_test.go b/internal/validate/semantic_test.go new file mode 100644 index 0000000..514246f --- /dev/null +++ b/internal/validate/semantic_test.go @@ -0,0 +1,56 @@ +package validate + +import "testing" + +func TestToNumber(t *testing.T) { + cases := []struct { + in any + val float64 + ok bool + name string + }{ + {float64(42), 42, true, "float64"}, + {int(7), 7, true, "int"}, + {int64(9), 9, true, "int64"}, + {"40", 0, false, "string"}, + {true, 0, false, "bool"}, + {nil, 0, false, "nil"}, + } + for _, tc := range cases { + got, ok := toNumber(tc.in) + if ok != tc.ok || (ok && got != tc.val) { + t.Errorf("toNumber(%v [%s]) = %v,%v want %v,%v", tc.in, tc.name, got, ok, tc.val, tc.ok) + } + } +} + +func TestSemanticChecksNonMap(t *testing.T) { + if errs := semanticChecks([]any{1, 2}); errs != nil { + t.Errorf("semanticChecks on non-map should be nil, got %v", errs) + } + if errs := targetChecks("not-a-map"); errs != nil { + t.Errorf("targetChecks on non-map should be nil, got %v", errs) + } +} + +func TestIframeRequiredFieldsBadTypes(t *testing.T) { + errs := iframeRequiredFields(map[string]any{ + "minHeight": "tall", // not a number + "resizable": "maybe", // not a bool + }) + if len(errs) != 2 { + t.Errorf("expected 2 errors for bad iframe field types, got %v", errs) + } +} + +func TestSandboxChecksNonStringIgnored(t *testing.T) { + if errs := sandboxChecks(map[string]any{"sandbox": 123}); errs != nil { + t.Errorf("non-string sandbox is schema-handled, got %v", errs) + } + if errs := sandboxChecks(map[string]any{}); errs != nil { + t.Errorf("absent sandbox should yield no semantic errors, got %v", errs) + } + if errs := sandboxChecks(map[string]any{"sandbox": " "}); len(errs) == 0 { + t.Error("empty/whitespace sandbox should error") + } +}