diff --git a/CHANGELOG.md b/CHANGELOG.md index baf1e0b..8feb493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 []` 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 ` 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 [--lang ] [--output ] [--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); ` list` (badges / links / media / prices / social / hours) accepts the screen id positionally; `remove` emits a projectable `{"deleted": true, "id": , "screen_id": }` record in machine modes; `--lang` paired with the flat, non-translatable `--name` field now warns on stderr; the `sections add --position ` 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. diff --git a/internal/api/projects.go b/internal/api/projects.go index 343a988..5161823 100644 --- a/internal/api/projects.go +++ b/internal/api/projects.go @@ -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 +} diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 3ccc2a0..96b6675 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -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 +func newProjectsSubdomainAvailableCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "subdomain-available ", + 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 ... +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 ", + 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 ", + 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 ", + 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 diff --git a/internal/cli/projects_test.go b/internal/cli/projects_test.go index df52259..581d280 100644 --- a/internal/cli/projects_test.go +++ b/internal/cli/projects_test.go @@ -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) + } +} diff --git a/internal/mcp/tools_projects.go b/internal/mcp/tools_projects.go index 737a630..c160cec 100644 --- a/internal/mcp/tools_projects.go +++ b/internal/mcp/tools_projects.go @@ -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) + }, + ) } diff --git a/internal/skills/stqry-reference.md b/internal/skills/stqry-reference.md index cf96080..1ddb536 100644 --- a/internal/skills/stqry-reference.md +++ b/internal/skills/stqry-reference.md @@ -536,6 +536,10 @@ stqry projects update [--name ] [--app-name ] [--subdomain ] [--pr Update a project. Pass --subdomain="" to clear. --primary-language flips the primary locale (existing translations preserved). stqry projects delete [--lang ] Delete a project entirely, or only one locale's translations with --lang. stqry projects urls 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 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 List the project's distribution tokens (token_type, enabled, uuid). +stqry projects tokens enable 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 Disable a distribution token. ``` #### `stqry projects tabs`