Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `stqry projects` gains pre-publish helpers: `subdomain-available <slug>` checks whether a subdomain can be claimed before `projects update --subdomain` (returns `{subdomain, available, reason}` where reason ∈ `invalid_format | reserved | taken`), and `tokens list|enable|disable <id> [<token-type>]` manages a project's distribution tokens (`app, demo, mobile_web, rental_mode, kiosk, legacy_app`) — enabling the `demo` / `mobile_web` token is the last step of "publish". Backed by new `GET .../subdomain_available`, `GET .../:id/tokens` and `PATCH .../:id/update_token` endpoints; MCP gets `subdomain_available` / `list_project_tokens` / `update_project_token` tools.
- `stqry media` gains URL-source video and binary download: `media create --type video --url <u>` now persists and validates a URL source (matching audio, backed by new server-side `url_*` columns, reachability validation, and an async metadata probe — the CLI's previous client-side rejection is removed), and `media download <id> [--lang <code>] [--output <path|->] [--urls-only]` streams the original uploaded binary via a new `GET /api/public/media_items/:id/download` endpoint (MCP `download_media`). Reference docs note that uploaded-audio `duration` populates asynchronously and that `create` defaults `short_title`→`title`→`name`.
- `stqry screens sections` gains quiz authoring and several ergonomic fixes: `add` / `update` can author `quiz_question` / `quiz_score` sections (`--quiz-question-id` / `--quiz-id`, optional `--zoomable`; `update` can re-point an existing section); `<kind> list` (badges / links / media / prices / social / hours) accepts the screen id positionally; `remove` emits a projectable `{"deleted": true, "id": <id>, "screen_id": <id>}` record in machine modes; `--lang` paired with the flat, non-translatable `--name` field now warns on stderr; the `sections add --position <n>` help/reference now documents that the server inserts at the given slot and shifts existing siblings down (positions stay contiguous, no follow-up `sections reorder` needed); and `--jq` in `--raw` mode emits nothing (instead of literal `null`) for a null result. MCP's `create_section` gained the same quiz support.
- The `--lang` whitelist now covers the full set of STQRY-supported content languages, generated from the server's accepted set rather than a hand-maintained subset, so any language the platform accepts is no longer rejected client-side.
Expand Down
40 changes: 40 additions & 0 deletions internal/api/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,43 @@ func DeleteProject(c *Client, id, language string) error {
func GetProjectURLs(c *Client, id string) ([]byte, error) {
return c.GetRaw(fmt.Sprintf("/api/public/projects/%s/urls", id), nil)
}

// SubdomainAvailable checks whether a project subdomain can be claimed.
// Returns {subdomain, available, reason} where reason is one of
// invalid_format / reserved / taken (null when available).
func SubdomainAvailable(c *Client, slug string) (map[string]interface{}, error) {
var resp map[string]interface{}
if err := c.Get("/api/public/projects/subdomain_available", map[string]string{"subdomain": slug}, &resp); err != nil {
return nil, err
}
return resp, nil
}

// ListProjectTokens returns a project's distribution tokens (token_type,
// enabled, uuid). The server wraps them under a "tokens" key.
func ListProjectTokens(c *Client, id string) ([]map[string]interface{}, error) {
var resp map[string]interface{}
if err := c.Get(fmt.Sprintf("/api/public/projects/%s/tokens", id), nil, &resp); err != nil {
return nil, err
}
out := []map[string]interface{}{}
if rows, ok := resp["tokens"].([]interface{}); ok {
for _, r := range rows {
if m, ok := r.(map[string]interface{}); ok {
out = append(out, m)
}
}
}
return out, nil
}

// UpdateProjectToken enables or disables one of a project's distribution
// tokens (e.g. demo, mobile_web) and returns the updated token record.
func UpdateProjectToken(c *Client, id, tokenType string, enabled bool) (map[string]interface{}, error) {
var resp map[string]interface{}
body := map[string]interface{}{"token_type": tokenType, "enabled": enabled}
if err := c.Patch(fmt.Sprintf("/api/public/projects/%s/update_token", id), body, &resp); err != nil {
return nil, err
}
return resp, nil
}
90 changes: 90 additions & 0 deletions internal/cli/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,101 @@ func newProjectsCmd() *cobra.Command {
cmd.AddCommand(newProjectsUpdateCmd())
cmd.AddCommand(newProjectsDeleteCmd())
cmd.AddCommand(newProjectsURLsCmd())
cmd.AddCommand(newProjectsSubdomainAvailableCmd())
cmd.AddCommand(newProjectsTokensCmd())
cmd.AddCommand(newProjectsTabsCmd())

return cmd
}

// projects subdomain-available <slug>
func newProjectsSubdomainAvailableCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "subdomain-available <slug>",
Short: "Check whether a project subdomain is available to claim",
Long: `Check whether a subdomain can be claimed before calling 'projects update --subdomain'.

Returns {subdomain, available, reason}. reason is one of invalid_format,
reserved, or taken (null when available). Use --jq '.available' -r in scripts.`,
Example: ` # Pre-check before claiming a subdomain
stqry projects subdomain-available liberty-tour

# Branch in a script
if [ "$(stqry projects subdomain-available liberty-tour --jq '.available' -r)" = "true" ]; then
stqry projects update 42 --subdomain liberty-tour
fi`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
result, err := api.SubdomainAvailable(activeClient, args[0])
if err != nil {
return err
}
return printer.PrintOne(result, nil)
},
}
return cmd
}

// projects tokens list|enable|disable <id> ...
func newProjectsTokensCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "tokens",
Short: "List and toggle a project's distribution tokens (demo, mobile_web, …)",
Long: `Manage the distribution tokens that gate where a project serves.

Enabling the 'demo' and 'mobile_web' tokens is the last step of "publish" —
without it, a project's preview/mobile-web URL 404s even after the subdomain
is set. Token types: app, demo, mobile_web, rental_mode, kiosk, legacy_app.`,
}

list := &cobra.Command{
Use: "list <project-id>",
Short: "List a project's tokens and their enabled state",
Example: " stqry projects tokens list 42",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
tokens, err := api.ListProjectTokens(activeClient, args[0])
if err != nil {
return err
}
rows := make([]map[string]interface{}, len(tokens))
copy(rows, tokens)
return printer.PrintList([]string{"token_type", "enabled", "uuid"}, rows, nil)
},
}

enable := &cobra.Command{
Use: "enable <project-id> <token-type>",
Short: "Enable a distribution token (e.g. demo, mobile_web)",
Example: " stqry projects tokens enable 42 demo",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
result, err := api.UpdateProjectToken(activeClient, args[0], args[1], true)
if err != nil {
return err
}
return printer.PrintOne(result, nil)
},
}

disable := &cobra.Command{
Use: "disable <project-id> <token-type>",
Short: "Disable a distribution token",
Example: " stqry projects tokens disable 42 demo",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
result, err := api.UpdateProjectToken(activeClient, args[0], args[1], false)
if err != nil {
return err
}
return printer.PrintOne(result, nil)
},
}

cmd.AddCommand(list, enable, disable)
return cmd
}

func newProjectsListCmd() *cobra.Command {
var page, perPage int
var q, tags, sortField, sortDirection string
Expand Down
96 changes: 96 additions & 0 deletions internal/cli/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,99 @@ func TestProjectsGetCmd(t *testing.T) {
t.Errorf("expected output to contain %q, got:\n%s", "City Tours", out)
}
}

// TestProjectsSubdomainAvailableCmd asserts the command hits the
// subdomain_available endpoint and surfaces the availability fields.
func TestProjectsSubdomainAvailableCmd(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/public/projects/subdomain_available" {
t.Errorf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("subdomain"); got != "liberty-tour" {
t.Errorf("unexpected subdomain param %q", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"subdomain": "liberty-tour", "available": true, "reason": nil})
}))
defer server.Close()
setupTestHome(t, server.URL)

origStdout := os.Stdout
rp, wp, _ := os.Pipe()
os.Stdout = wp
cmd := newRootCmd()
cmd.SetArgs([]string{"projects", "subdomain-available", "liberty-tour", "--jq", ".available", "-r"})
cmd.SetErr(io.Discard)
err := cmd.Execute()
wp.Close()
os.Stdout = origStdout
out, _ := io.ReadAll(rp)
rp.Close()
if err != nil {
t.Fatalf("Execute: %v", err)
}
if strings.TrimSpace(string(out)) != "true" {
t.Errorf("expected --jq '.available' -r to print true, got %q", out)
}
}

// TestProjectsTokensEnableCmd asserts `projects tokens enable` PATCHes
// update_token with enabled=true.
func TestProjectsTokensEnableCmd(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch || r.URL.Path != "/api/public/projects/42/update_token" {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
_ = json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"token_type": "demo", "enabled": true, "uuid": "abc"})
}))
defer server.Close()
setupTestHome(t, server.URL)

cmd := newRootCmd()
cmd.SetArgs([]string{"projects", "tokens", "enable", "42", "demo", "--quiet"})
cmd.SetErr(io.Discard)
cmd.SetOut(io.Discard)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if body["token_type"] != "demo" || body["enabled"] != true {
t.Errorf("unexpected PATCH body: %v", body)
}
}

// TestProjectsTokensListCmd asserts `projects tokens list` reads the tokens array.
func TestProjectsTokensListCmd(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/public/projects/42/tokens" {
t.Errorf("unexpected path %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"tokens": []interface{}{
map[string]interface{}{"token_type": "demo", "enabled": false, "uuid": "x"},
map[string]interface{}{"token_type": "mobile_web", "enabled": true, "uuid": "y"},
}})
}))
defer server.Close()
setupTestHome(t, server.URL)

origStdout := os.Stdout
rp, wp, _ := os.Pipe()
os.Stdout = wp
cmd := newRootCmd()
cmd.SetArgs([]string{"projects", "tokens", "list", "42", "--jq", ".[].token_type", "-r"})
cmd.SetErr(io.Discard)
err := cmd.Execute()
wp.Close()
os.Stdout = origStdout
out, _ := io.ReadAll(rp)
rp.Close()
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(string(out), "demo") || !strings.Contains(string(out), "mobile_web") {
t.Errorf("expected token types in output, got %q", out)
}
}
91 changes: 91 additions & 0 deletions internal/mcp/tools_projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,95 @@ func registerProjectTools(s *server.MCPServer, sess *Session) {
return mcpgo.NewToolResultText(string(body)), nil
},
)

// subdomain_available: check whether a project subdomain can be claimed
s.AddTool(
mcpgo.NewTool("subdomain_available",
mcpgo.WithDescription("Check whether a project subdomain is available to claim before calling update_project with subdomain. Returns {subdomain, available, reason} (reason: invalid_format | reserved | taken, null when available)."),
mcpgo.WithString("subdomain",
mcpgo.Required(),
mcpgo.Description("The subdomain slug to check (case-insensitive)"),
),
),
func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
slug := req.GetString("subdomain", "")
if slug == "" {
return mcpgo.NewToolResultError("subdomain is required"), nil
}
client, err := ResolveClient(sess)
if err != nil {
return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil
}
result, err := api.SubdomainAvailable(client, slug)
if err != nil {
return mcpgo.NewToolResultError(fmt.Sprintf("checking subdomain: %v", err)), nil
}
return jsonResult(result)
},
)

// list_project_tokens: list a project's distribution tokens
s.AddTool(
mcpgo.NewTool("list_project_tokens",
mcpgo.WithDescription("List a project's distribution tokens and their enabled state (token_type, enabled, uuid). Enabling the demo / mobile_web token is the last step of publishing a project."),
mcpgo.WithString("id",
mcpgo.Required(),
mcpgo.Description("The project ID"),
),
),
func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
id := req.GetString("id", "")
if id == "" {
return mcpgo.NewToolResultError("id is required"), nil
}
client, err := ResolveClient(sess)
if err != nil {
return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil
}
tokens, err := api.ListProjectTokens(client, id)
if err != nil {
return mcpgo.NewToolResultError(fmt.Sprintf("listing project tokens: %v", err)), nil
}
return jsonResult(map[string]interface{}{"tokens": tokens})
},
)

// update_project_token: enable/disable a project distribution token
s.AddTool(
mcpgo.NewTool("update_project_token",
mcpgo.WithDescription("Enable or disable one of a project's distribution tokens (e.g. demo, mobile_web). token_type is one of: app, demo, mobile_web, rental_mode, kiosk, legacy_app."),
mcpgo.WithString("id",
mcpgo.Required(),
mcpgo.Description("The project ID"),
),
mcpgo.WithString("token_type",
mcpgo.Required(),
mcpgo.Description("Token type: app, demo, mobile_web, rental_mode, kiosk, or legacy_app"),
),
mcpgo.WithBoolean("enabled",
mcpgo.Required(),
mcpgo.Description("Whether the token should be enabled"),
),
),
func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
id := req.GetString("id", "")
if id == "" {
return mcpgo.NewToolResultError("id is required"), nil
}
tokenType := req.GetString("token_type", "")
if tokenType == "" {
return mcpgo.NewToolResultError("token_type is required"), nil
}
enabled := req.GetBool("enabled", false)
client, err := ResolveClient(sess)
if err != nil {
return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil
}
result, err := api.UpdateProjectToken(client, id, tokenType, enabled)
if err != nil {
return mcpgo.NewToolResultError(fmt.Sprintf("updating project token: %v", err)), nil
}
return jsonResult(result)
},
)
}
4 changes: 4 additions & 0 deletions internal/skills/stqry-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,10 @@ stqry projects update <id> [--name <s>] [--app-name <s>] [--subdomain <s>] [--pr
Update a project. Pass --subdomain="" to clear. --primary-language flips the primary locale (existing translations preserved).
stqry projects delete <id> [--lang <code>] Delete a project entirely, or only one locale's translations with --lang.
stqry projects urls <id> Download the project's URLs as raw CSV (custom domains, subdomains, app store links). The output is CSV, not JSON; --json / --jq / --quiet do not apply.
stqry projects subdomain-available <slug> Check whether a subdomain can be claimed before `projects update --subdomain`. Prints {subdomain, available, reason}; reason ∈ invalid_format | reserved | taken (null when available). Use --jq '.available' -r in scripts.
stqry projects tokens list <id> List the project's distribution tokens (token_type, enabled, uuid).
stqry projects tokens enable <id> <token-type> Enable a distribution token (e.g. demo, mobile_web). Enabling demo/mobile_web is the last step of "publish" — without it the preview/mobile-web URL 404s even after the subdomain is set. Token types: app, demo, mobile_web, rental_mode, kiosk, legacy_app.
stqry projects tokens disable <id> <token-type> Disable a distribution token.
```

#### `stqry projects tabs`
Expand Down
Loading