mirror of
https://github.com/coder/registry.git
synced 2026-06-02 20:48:14 +00:00
feat: add skills as namespace-level catalogue entries with external source repos (#884)
## Summary
Adds skills as a catalogue resource type in the registry. Each namespace
declares its skill source repos and per-skill presentation metadata in
`registry/<namespace>/skills/README.md`. The registry-server build
pipeline clones source repos, auto-discovers skills, and serves them
with the metadata defined here.
## Catalogue format
The skills README uses structured YAML frontmatter with nested per-skill
metadata:
```yaml
---
icon: ../../../.icons/coder.svg
sources:
- repo: coder/skills@main
skills:
setup:
display_name: Setup & Configuration
icon: ../../../.icons/coder.svg
tags: [coder, deployment, configuration]
---
```
- `icon` (top-level): default icon for skills without a per-skill
override
- `sources[].repo`: GitHub repo to clone (`owner/repo@ref`)
- `sources[].skills`: per-skill overrides for `display_name`,
`description`, `icon`, and `tags`
- Multiple repos per namespace are supported
- Skills not listed in the `skills` map are still discovered with
default metadata
- `name` and `description` always come from the source repo's SKILL.md
unless overridden
## Changes
- `registry/coder/skills/README.md`: Coder namespace pointing to
`coder/skills@main` with per-skill metadata
- `registry/DevelopmentCats/skills/README.md`: Test namespace pointing
to `DevelopmentCats/skills@main` (remove before merge)
- `registry/DevelopmentCats/README.md` + `.images/avatar.svg`: Test
namespace profile (remove before merge)
- `.github/workflows/deploy-registry.yaml`: Added
`registry/**/skills/**` path trigger
- `.github/workflows/release.yml`: Skill/module path detection in tag
extraction
- `.github/workflows/version-bump.yaml`: Added `registry/**/skills/**`
path trigger
- `cmd/readmevalidation/repostructure.go`: Added `skills` to supported
namespace directories
## Related
-
[registry-server#442](https://github.com/coder/registry-server/pull/442):
Build pipeline, API, MCP, frontend, and well-known discovery for skills
- [coder/skills](https://github.com/coder/skills): Coder's official
skills source repo
- [Problem
Document](https://www.notion.so/35dd579be59281a4b657d02174667e4f):
Skills as First-Class Registry Catalogue Items
> 🤖 This PR was updated with the help of Coder Agents.
This commit is contained in:
@@ -9,11 +9,12 @@ on:
|
|||||||
# Matches release/<namespace>/<resource_name>/<semantic_version>
|
# Matches release/<namespace>/<resource_name>/<semantic_version>
|
||||||
# (e.g., "release/whizus/exoscale-zone/v1.0.13")
|
# (e.g., "release/whizus/exoscale-zone/v1.0.13")
|
||||||
- "release/*/*/v*.*.*"
|
- "release/*/*/v*.*.*"
|
||||||
branches: # Templates get released when merged to main
|
branches: # Templates and skills get released when merged to main
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/deploy-registry.yaml"
|
- ".github/workflows/deploy-registry.yaml"
|
||||||
- "registry/**/templates/**"
|
- "registry/**/templates/**"
|
||||||
|
- "registry/**/skills/**"
|
||||||
- "registry/**/README.md"
|
- "registry/**/README.md"
|
||||||
- ".icons/**"
|
- ".icons/**"
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ jobs:
|
|||||||
echo "namespace=$NAMESPACE" >> $GITHUB_OUTPUT
|
echo "namespace=$NAMESPACE" >> $GITHUB_OUTPUT
|
||||||
echo "module=$MODULE" >> $GITHUB_OUTPUT
|
echo "module=$MODULE" >> $GITHUB_OUTPUT
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
echo "module_path=registry/$NAMESPACE/modules/$MODULE" >> $GITHUB_OUTPUT
|
echo "module_path=registry/$NAMESPACE/modules/$MODULE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
RELEASE_TITLE="$NAMESPACE/$MODULE $VERSION"
|
RELEASE_TITLE="$NAMESPACE/$MODULE $VERSION"
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// skillsRepoSpecRe matches the "owner/repo" or "owner/repo@ref" format used
|
||||||
|
// in the skills README sources frontmatter. Owners and repo names allow
|
||||||
|
// alphanumerics, hyphens, underscores, and dots. Refs allow the same plus
|
||||||
|
// forward slashes for paths like refs/heads/main.
|
||||||
|
var skillsRepoSpecRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(@[a-zA-Z0-9_./-]+)?$`)
|
||||||
|
|
||||||
|
// skillsIconPrefix is the relative path prefix from a skills README to the
|
||||||
|
// repo-level .icons directory. The skills README lives at depth 3
|
||||||
|
// (registry/<namespace>/skills/README.md), so the prefix is three levels up.
|
||||||
|
// This is distinct from modules and templates, which live at depth 4 and use
|
||||||
|
// "../../../../.icons/".
|
||||||
|
const skillsIconPrefix = "../../../.icons/"
|
||||||
|
|
||||||
|
// skillOverride holds per-skill presentation metadata defined in the
|
||||||
|
// registry README. All fields are optional.
|
||||||
|
type skillOverride struct {
|
||||||
|
DisplayName string `yaml:"display_name"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Icon string `yaml:"icon"`
|
||||||
|
Tags []string `yaml:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// skillSource is one entry in the sources list, describing a single source
|
||||||
|
// repo and optional per-skill overrides.
|
||||||
|
type skillSource struct {
|
||||||
|
Repo string `yaml:"repo"`
|
||||||
|
Skills map[string]skillOverride `yaml:"skills"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// coderSkillsFrontmatter is the YAML frontmatter schema for
|
||||||
|
// registry/<namespace>/skills/README.md.
|
||||||
|
type coderSkillsFrontmatter struct {
|
||||||
|
Icon string `yaml:"icon"`
|
||||||
|
Sources []skillSource `yaml:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// supportedSkillsTopLevelKeys lists the keys allowed at the root of the
|
||||||
|
// skills README frontmatter. Nested keys under sources are validated
|
||||||
|
// separately because the typed unmarshal handles them.
|
||||||
|
var supportedSkillsTopLevelKeys = []string{"icon", "sources"}
|
||||||
|
|
||||||
|
// coderSkillsReadme represents a parsed skills README file.
|
||||||
|
type coderSkillsReadme struct {
|
||||||
|
filePath string
|
||||||
|
body string
|
||||||
|
frontmatter coderSkillsFrontmatter
|
||||||
|
}
|
||||||
|
|
||||||
|
// separateSkillsFrontmatter is like separateFrontmatter but preserves
|
||||||
|
// indentation in the frontmatter block. The skills README uses nested YAML
|
||||||
|
// (per-skill metadata under each source), which the indentation-trimming
|
||||||
|
// behavior of the shared separateFrontmatter helper destroys.
|
||||||
|
func separateSkillsFrontmatter(readmeText string) (frontmatter string, body string, err error) {
|
||||||
|
if readmeText == "" {
|
||||||
|
return "", "", xerrors.New("README is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
const fence = "---"
|
||||||
|
var fmBuilder strings.Builder
|
||||||
|
var bodyBuilder strings.Builder
|
||||||
|
fenceCount := 0
|
||||||
|
|
||||||
|
lineScanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(readmeText)))
|
||||||
|
for lineScanner.Scan() {
|
||||||
|
nextLine := lineScanner.Text()
|
||||||
|
if fenceCount < 2 && strings.TrimSpace(nextLine) == fence {
|
||||||
|
fenceCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fenceCount == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if fenceCount >= 2 {
|
||||||
|
bodyBuilder.WriteString(nextLine)
|
||||||
|
bodyBuilder.WriteString("\n")
|
||||||
|
} else {
|
||||||
|
fmBuilder.WriteString(nextLine)
|
||||||
|
fmBuilder.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fenceCount < 2 {
|
||||||
|
return "", "", xerrors.New("README does not have two sets of frontmatter fences")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(fmBuilder.String()) == "" {
|
||||||
|
return "", "", xerrors.New("readme has frontmatter fences but no frontmatter content")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmBuilder.String(), strings.TrimSpace(bodyBuilder.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPermittedSkillsIconURL validates that an icon URL references the
|
||||||
|
// repo-level .icons directory using the 3-deep prefix appropriate for
|
||||||
|
// skills READMEs, and that the file exists on disk.
|
||||||
|
func isPermittedSkillsIconURL(checkURL string, readmeFilePath string) error {
|
||||||
|
if !strings.HasPrefix(checkURL, skillsIconPrefix) {
|
||||||
|
return xerrors.Errorf("icon URL %q must reference the top-level .icons directory using %q", checkURL, skillsIconPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
readmeDir := path.Dir(readmeFilePath)
|
||||||
|
resolvedPath := path.Join(readmeDir, checkURL)
|
||||||
|
|
||||||
|
if _, err := os.Stat(resolvedPath); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return xerrors.Errorf("icon file does not exist at resolved path %q (referenced as %q)", resolvedPath, checkURL)
|
||||||
|
}
|
||||||
|
return xerrors.Errorf("error checking icon file at %q: %v", resolvedPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSkillsIconURL(iconURL string, filePath string) []error {
|
||||||
|
if iconURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
if strings.HasPrefix(iconURL, "http://") || strings.HasPrefix(iconURL, "https://") {
|
||||||
|
errs = append(errs, xerrors.Errorf("icon URL must reference the top-level .icons directory, not an absolute URL %q", iconURL))
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := isPermittedSkillsIconURL(iconURL, filePath); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSkillsTopLevelKeys parses the (indentation-preserved) frontmatter
|
||||||
|
// as a YAML map and verifies that every top-level key is in the supported
|
||||||
|
// set. This catches typos like "source:" vs "sources:".
|
||||||
|
func validateSkillsTopLevelKeys(fm string) []error {
|
||||||
|
var rawKeys map[string]any
|
||||||
|
if err := yaml.Unmarshal([]byte(fm), &rawKeys); err != nil {
|
||||||
|
return []error{xerrors.Errorf("failed to parse frontmatter as YAML map: %v", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for key := range rawKeys {
|
||||||
|
if !slices.Contains(supportedSkillsTopLevelKeys, key) {
|
||||||
|
errs = append(errs, xerrors.Errorf("detected unknown top-level key %q (allowed: %s)", key, strings.Join(supportedSkillsTopLevelKeys, ", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSkillsSources(sources []skillSource, filePath string) []error {
|
||||||
|
if len(sources) == 0 {
|
||||||
|
return []error{xerrors.New("at least one source repo is required under 'sources'")}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for i, src := range sources {
|
||||||
|
if src.Repo == "" {
|
||||||
|
errs = append(errs, xerrors.Errorf("sources[%d]: missing required 'repo' field", i))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !skillsRepoSpecRe.MatchString(src.Repo) {
|
||||||
|
errs = append(errs, xerrors.Errorf("sources[%d]: repo %q is not a valid owner/repo or owner/repo@ref spec", i, src.Repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
for slug, override := range src.Skills {
|
||||||
|
if !validNameRe.MatchString(slug) {
|
||||||
|
errs = append(errs, xerrors.Errorf("sources[%d]: skill slug %q contains invalid characters (only alphanumeric and hyphens allowed)", i, slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, iconErr := range validateSkillsIconURL(override.Icon, filePath) {
|
||||||
|
errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, iconErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCoderResourceTags returns an error for nil tags, which is
|
||||||
|
// fine for modules/templates that require tags but not for skills
|
||||||
|
// where tags are an optional override.
|
||||||
|
if override.Tags != nil {
|
||||||
|
if err := validateCoderResourceTags(override.Tags); err != nil {
|
||||||
|
errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCoderSkillsFrontmatter(filePath string, fm coderSkillsFrontmatter) []error {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
for _, err := range validateSkillsIconURL(fm.Icon, filePath) {
|
||||||
|
errs = append(errs, addFilePathToError(filePath, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, err := range validateSkillsSources(fm.Sources, filePath) {
|
||||||
|
errs = append(errs, addFilePathToError(filePath, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCoderSkillsReadme(rm readme) (coderSkillsReadme, []error) {
|
||||||
|
fm, body, err := separateSkillsFrontmatter(rm.rawText)
|
||||||
|
if err != nil {
|
||||||
|
return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
keyErrs := validateSkillsTopLevelKeys(fm)
|
||||||
|
if len(keyErrs) != 0 {
|
||||||
|
var remapped []error
|
||||||
|
for _, e := range keyErrs {
|
||||||
|
remapped = append(remapped, addFilePathToError(rm.filePath, e))
|
||||||
|
}
|
||||||
|
return coderSkillsReadme{}, remapped
|
||||||
|
}
|
||||||
|
|
||||||
|
yml := coderSkillsFrontmatter{}
|
||||||
|
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
|
||||||
|
return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return coderSkillsReadme{
|
||||||
|
filePath: rm.filePath,
|
||||||
|
body: body,
|
||||||
|
frontmatter: yml,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCoderSkillsReadmeFiles(rms []readme) ([]coderSkillsReadme, error) {
|
||||||
|
var parsed []coderSkillsReadme
|
||||||
|
var parsingErrs []error
|
||||||
|
for _, rm := range rms {
|
||||||
|
p, errs := parseCoderSkillsReadme(rm)
|
||||||
|
if len(errs) != 0 {
|
||||||
|
parsingErrs = append(parsingErrs, errs...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed = append(parsed, p)
|
||||||
|
}
|
||||||
|
if len(parsingErrs) != 0 {
|
||||||
|
return nil, validationPhaseError{
|
||||||
|
phase: validationPhaseReadme,
|
||||||
|
errors: parsingErrs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAllCoderSkillsReadmes(readmes []coderSkillsReadme) error {
|
||||||
|
var validationErrs []error
|
||||||
|
for _, rm := range readmes {
|
||||||
|
errs := validateCoderSkillsFrontmatter(rm.filePath, rm.frontmatter)
|
||||||
|
if len(errs) > 0 {
|
||||||
|
validationErrs = append(validationErrs, errs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(validationErrs) != 0 {
|
||||||
|
return validationPhaseError{
|
||||||
|
phase: validationPhaseReadme,
|
||||||
|
errors: validationErrs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateSkillsReadmeFiles walks registry/<namespace>/skills/README.md
|
||||||
|
// entries, skipping namespaces that do not have a skills directory.
|
||||||
|
func aggregateSkillsReadmeFiles() ([]readme, error) {
|
||||||
|
namespaceDirs, err := os.ReadDir(rootRegistryPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var allReadmeFiles []readme
|
||||||
|
var errs []error
|
||||||
|
for _, nDir := range namespaceDirs {
|
||||||
|
if !nDir.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
skillsReadmePath := path.Join(rootRegistryPath, nDir.Name(), "skills", "README.md")
|
||||||
|
rmBytes, err := os.ReadFile(skillsReadmePath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allReadmeFiles = append(allReadmeFiles, readme{
|
||||||
|
filePath: skillsReadmePath,
|
||||||
|
rawText: string(rmBytes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) != 0 {
|
||||||
|
return nil, validationPhaseError{
|
||||||
|
phase: validationPhaseFile,
|
||||||
|
errors: errs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allReadmeFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAllCoderSkills() error {
|
||||||
|
allReadmeFiles, err := aggregateSkillsReadmeFiles()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info(context.Background(), "processing skills README files", "num_files", len(allReadmeFiles))
|
||||||
|
if len(allReadmeFiles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
readmes, err := parseCoderSkillsReadmeFiles(allReadmeFiles)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateAllCoderSkillsReadmes(readmes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info(context.Background(), "processed all skills README files", "num_files", len(readmes))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -39,6 +39,10 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
|
err = validateAllCoderSkills()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
|
||||||
if len(errs) == 0 {
|
if len(errs) == 0 {
|
||||||
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
|
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
|
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images", "skills")
|
||||||
|
|
||||||
// validNameRe validates that names contain only alphanumeric characters and hyphens
|
// validNameRe validates that names contain only alphanumeric characters and hyphens
|
||||||
var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
|
var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
icon: ../../../.icons/coder.svg
|
||||||
|
sources:
|
||||||
|
- repo: coder/skills@main
|
||||||
|
skills:
|
||||||
|
setup:
|
||||||
|
display_name: Coder Setup
|
||||||
|
icon: ../../../.icons/coder.svg
|
||||||
|
tags: [coder, deployment, configuration]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Coder Skills
|
||||||
|
|
||||||
|
Agent skills maintained by [Coder](https://coder.com) for installing,
|
||||||
|
configuring, and developing with the Coder platform.
|
||||||
|
|
||||||
|
Skills are sourced from [coder/skills](https://github.com/coder/skills)
|
||||||
|
and served through the registry's API, MCP tools, and
|
||||||
|
[well-known discovery endpoint](https://agentskills.io/specification).
|
||||||
|
|
||||||
|
## Available Skills
|
||||||
|
|
||||||
|
| Skill | Description |
|
||||||
|
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| [Coder Setup](https://registry.coder.com/skills/coder/setup) | Install, deploy, or bootstrap a new Coder deployment end-to-end. Covers Docker, Kubernetes/Helm, VM, cloud, HTTPS/domain setup, first admin creation, starter templates, and first workspace. |
|
||||||
Reference in New Issue
Block a user