From 7f39377799ec089b92fc65ace67ea35a76c0f8c8 Mon Sep 17 00:00:00 2001 From: steven Date: Thu, 3 Sep 2026 17:24:02 +0800 Subject: [PATCH] Add FeedMob Workspace CLI namespace --- README.md | 21 +++++++++-- lib/feedmob/cli.rb | 1 + lib/feedmob/cli/commands/auth.rb | 18 ++++++++++ lib/feedmob/cli/commands/request.rb | 22 ++++++++++++ lib/feedmob/cli/registry.rb | 10 ++++++ lib/feedmob/cli/service_definitions.rb | 38 ++++++++++++++++++++ lib/feedmob/cli/services.rb | 48 +------------------------- test/cli_commands_test.rb | 35 +++++++++++++++++++ test/cli_help_test.rb | 1 + test/services_test.rb | 12 +++++++ 10 files changed, 157 insertions(+), 49 deletions(-) create mode 100644 lib/feedmob/cli/service_definitions.rb diff --git a/README.md b/README.md index 5ae795d..5946f4f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # FeedMob CLI (`fm`) `fm` is the command-line interface for FeedMob services. It manages isolated -credentials for Pixel, Time Off, Femini, and Pages, verifies authentication, and issues +credentials for Pixel, Time Off, Femini, Pages, and FeedMob Workspace, verifies authentication, and issues safe, service-specific API requests — with stable JSON output designed for scripts and automation. @@ -33,6 +33,10 @@ fm pages share enable [--rotate] fm pages share revoke fm pages asset upload fm pages request get +fm workspace auth login [--token-stdin] +fm workspace auth status +fm workspace auth logout +fm workspace request get ``` ## Installation @@ -84,6 +88,7 @@ Each service keeps its own credential, resolved in this order: | Time Off | `FEEDMOB_TIME_OFF_TOKEN` | `https://time-off.feedmob.com` | `GET /api/v1/me` | Deletes local store only | | Femini | `FEEDMOB_FEMINI_TOKEN` | `https://assistant.feedmob.ai` | `GET /clients.json?name_cont=__feedmob_cli_auth_probe__` | Deletes local store only | | Pages | `FEEDMOB_PAGES_TOKEN` | `https://pages.feedmob.com` | `GET /api/me` | Deletes local store only | +| FeedMob Workspace | `FEEDMOB_WORKSPACE_TOKEN` | `https://admin.feedmob.com` | `GET /api/v1/me` | Deletes local store only | ```sh # Interactive, hidden input; the token never appears in argv or history @@ -96,7 +101,7 @@ printf '%s' "$FEEDMOB_PIXEL_TOKEN" | fm pixel auth login --token-stdin Endpoints can be overridden with `FEEDMOB_PIXEL_BASE_URL`, `FEEDMOB_TIME_OFF_BASE_URL`, `FEEDMOB_FEMINI_BASE_URL`, and -`FEEDMOB_PAGES_BASE_URL`. Overrides must use HTTPS; plain HTTP is only accepted +`FEEDMOB_PAGES_BASE_URL`, and `FEEDMOB_WORKSPACE_BASE_URL`. Overrides must use HTTPS; plain HTTP is only accepted for loopback addresses (`localhost`, `127.0.0.1`, `::1`) together with an explicit `FEEDMOB_ALLOW_INSECURE_HTTP=1` — never set that variable in shared or production environments. @@ -143,6 +148,18 @@ fm pages publish --owner growth --html-file report.html --visibility unlisted fm pages asset upload chart.png ``` +FeedMob Workspace is the internal, read-only API for shared operational data. +Obtain a personal access token from FeedMob SSO, then authenticate without +placing the token in shell history. Workspace requests are GET-only and must +stay under `/api/v1/`; access to individual resources is enforced by the +server-side Workspace privileges. Consult the SSO-protected API Reference for +available resources rather than treating CLI help as an endpoint catalog. + +```sh +fm workspace auth login +fm workspace request get /api/v1/me +``` + `publish` requires `--owner` and `--html-file`. `update` accepts an HTML replacement with `--html-file`, small find/replace changes from `--edits-file`, and optional metadata; the HTML replacement and edits are mutually exclusive. diff --git a/lib/feedmob/cli.rb b/lib/feedmob/cli.rb index 3b19fe1..15dd56b 100644 --- a/lib/feedmob/cli.rb +++ b/lib/feedmob/cli.rb @@ -43,6 +43,7 @@ def start(argv = ARGV, stdout: $stdout, stderr: $stderr) pages [SUBCOMMAND] Work with FeedMob Pages pixel [SUBCOMMAND] Work with FeedMob Pixel time-off [SUBCOMMAND] Work with FeedMob Time Off + workspace [SUBCOMMAND] Work with FeedMob Workspace HELP return 0 end diff --git a/lib/feedmob/cli/commands/auth.rb b/lib/feedmob/cli/commands/auth.rb index 752eb28..f68b7d7 100644 --- a/lib/feedmob/cli/commands/auth.rb +++ b/lib/feedmob/cli/commands/auth.rb @@ -175,6 +175,24 @@ class PagesAuthLogout < AuthLogout def service_name = 'pages' end + + class WorkspaceAuthLogin < AuthLogin + desc 'Verify and securely save a FeedMob Workspace credential' + + def service_name = 'workspace' + end + + class WorkspaceAuthStatus < AuthStatus + desc 'Show the authenticated FeedMob Workspace identity' + + def service_name = 'workspace' + end + + class WorkspaceAuthLogout < AuthLogout + desc 'Remove the local FeedMob Workspace credential' + + def service_name = 'workspace' + end end end end diff --git a/lib/feedmob/cli/commands/request.rb b/lib/feedmob/cli/commands/request.rb index e41eb46..1d853a6 100644 --- a/lib/feedmob/cli/commands/request.rb +++ b/lib/feedmob/cli/commands/request.rb @@ -83,6 +83,28 @@ class PagesRequestGet < RequestGet def service_name = 'pages' end + + class WorkspaceRequestGet < RequestGet + desc 'Perform an authenticated GET request against the FeedMob Workspace API' + + def call(path:, **) + validate_workspace_path!(path) + super + end + + def service_name = 'workspace' + + private + + def validate_workspace_path!(path) + return if path.to_s.start_with?('/api/v1/') + + raise Error.new( + code: 'invalid_path', + message: 'FeedMob Workspace requests must target a path under /api/v1/.' + ) + end + end end end end diff --git a/lib/feedmob/cli/registry.rb b/lib/feedmob/cli/registry.rb index c27fa7c..f65d131 100644 --- a/lib/feedmob/cli/registry.rb +++ b/lib/feedmob/cli/registry.rb @@ -67,6 +67,16 @@ module Commands request.register 'get', PagesRequestGet end end + register 'workspace' do |workspace| + workspace.register 'auth' do |auth| + auth.register 'login', WorkspaceAuthLogin + auth.register 'status', WorkspaceAuthStatus + auth.register 'logout', WorkspaceAuthLogout + end + workspace.register 'request' do |request| + request.register 'get', WorkspaceRequestGet + end + end end end end diff --git a/lib/feedmob/cli/service_definitions.rb b/lib/feedmob/cli/service_definitions.rb new file mode 100644 index 0000000..d155766 --- /dev/null +++ b/lib/feedmob/cli/service_definitions.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module FeedMob + module CLI + module Services + DEFINITIONS = { + 'pixel' => { + label: 'Pixel', base_url: 'https://feedmob-pixel-dashboard.feedmob.com/rails', + base_url_env: 'FEEDMOB_PIXEL_BASE_URL', token_env: 'FEEDMOB_PIXEL_TOKEN', token_prefix: 'fmpat_', + identity_path: '/api/v1/cli/me', identity_response: true, revoke_path: '/api/v1/cli/token', + keychain_service: 'com.feedmob.fm.pixel' + }, + 'time-off' => { + label: 'Time Off', base_url: 'https://time-off.feedmob.com', base_url_env: 'FEEDMOB_TIME_OFF_BASE_URL', + token_env: 'FEEDMOB_TIME_OFF_TOKEN', token_prefix: 'fmtopat_', identity_path: '/api/v1/me', + identity_response: true, revoke_path: nil, keychain_service: 'com.feedmob.fm.time-off' + }, + 'femini' => { + label: 'Femini', base_url: 'https://assistant.feedmob.ai', base_url_env: 'FEEDMOB_FEMINI_BASE_URL', + token_env: 'FEEDMOB_FEMINI_TOKEN', token_prefix: nil, + identity_path: '/clients.json?name_cont=__feedmob_cli_auth_probe__', identity_response: false, + revoke_path: nil, keychain_service: 'com.feedmob.fm.femini' + }, + 'pages' => { + label: 'Pages', base_url: 'https://pages.feedmob.com', base_url_env: 'FEEDMOB_PAGES_BASE_URL', + token_env: 'FEEDMOB_PAGES_TOKEN', token_prefix: nil, identity_path: '/api/me', identity_response: true, + revoke_path: nil, keychain_service: 'com.feedmob.fm.pages' + }, + 'workspace' => { + label: 'FeedMob Workspace', base_url: 'https://admin.feedmob.com', + base_url_env: 'FEEDMOB_WORKSPACE_BASE_URL', token_env: 'FEEDMOB_WORKSPACE_TOKEN', + token_prefix: 'fmapat_', identity_path: '/api/v1/me', identity_response: true, revoke_path: nil, + keychain_service: 'com.feedmob.fm.workspace' + } + }.freeze + end + end +end diff --git a/lib/feedmob/cli/services.rb b/lib/feedmob/cli/services.rb index cbacb54..d5b228e 100644 --- a/lib/feedmob/cli/services.rb +++ b/lib/feedmob/cli/services.rb @@ -3,57 +3,11 @@ require 'uri' require_relative 'error' require_relative 'service' +require_relative 'service_definitions' module FeedMob module CLI module Services - DEFINITIONS = { - 'pixel' => { - label: 'Pixel', - base_url: 'https://feedmob-pixel-dashboard.feedmob.com/rails', - base_url_env: 'FEEDMOB_PIXEL_BASE_URL', - token_env: 'FEEDMOB_PIXEL_TOKEN', - token_prefix: 'fmpat_', - identity_path: '/api/v1/cli/me', - identity_response: true, - revoke_path: '/api/v1/cli/token', - keychain_service: 'com.feedmob.fm.pixel' - }, - 'time-off' => { - label: 'Time Off', - base_url: 'https://time-off.feedmob.com', - base_url_env: 'FEEDMOB_TIME_OFF_BASE_URL', - token_env: 'FEEDMOB_TIME_OFF_TOKEN', - token_prefix: 'fmtopat_', - identity_path: '/api/v1/me', - identity_response: true, - revoke_path: nil, - keychain_service: 'com.feedmob.fm.time-off' - }, - 'femini' => { - label: 'Femini', - base_url: 'https://assistant.feedmob.ai', - base_url_env: 'FEEDMOB_FEMINI_BASE_URL', - token_env: 'FEEDMOB_FEMINI_TOKEN', - token_prefix: nil, - identity_path: '/clients.json?name_cont=__feedmob_cli_auth_probe__', - identity_response: false, - revoke_path: nil, - keychain_service: 'com.feedmob.fm.femini' - }, - 'pages' => { - label: 'Pages', - base_url: 'https://pages.feedmob.com', - base_url_env: 'FEEDMOB_PAGES_BASE_URL', - token_env: 'FEEDMOB_PAGES_TOKEN', - token_prefix: nil, - identity_path: '/api/me', - identity_response: true, - revoke_path: nil, - keychain_service: 'com.feedmob.fm.pages' - } - }.freeze - module_function def fetch(name, env: ENV) diff --git a/test/cli_commands_test.rb b/test/cli_commands_test.rb index c31227e..89d4920 100644 --- a/test/cli_commands_test.rb +++ b/test/cli_commands_test.rb @@ -184,6 +184,41 @@ def test_femini_request_get_uses_the_femini_credential assert_equal({ 'series' => [] }, JSON.parse(stdout).dig('data', 'response')) end + def test_workspace_request_get_is_limited_to_the_versioned_workspace_api + credentials = FakeCredentials.new( + credential: FeedMob::CLI::Credential.new(value: 'fmapat_workspace', source: 'keychain') + ) + workspace_client = FakeClient.new( + [FeedMob::CLI::HTTP::Response.new(status: 200, headers: {}, data: { 'user' => { 'id' => 42 } })] + ) + use_runtime( + credentials:, + clients: { 'pixel' => FakeClient.new, 'time-off' => FakeClient.new, 'workspace' => workspace_client } + ) + + stdout, = run_cli('workspace', 'request', 'get', '/api/v1/me', '--json') + + assert_equal( + { method: :get, path: '/api/v1/me', token: 'fmapat_workspace' }, + workspace_client.requests.fetch(0) + ) + assert_equal({ 'user' => { 'id' => 42 } }, JSON.parse(stdout).dig('data', 'response')) + end + + def test_workspace_request_get_rejects_paths_outside_the_versioned_workspace_api + credentials = FakeCredentials.new + workspace_client = FakeClient.new + use_runtime( + credentials:, + clients: { 'pixel' => FakeClient.new, 'time-off' => FakeClient.new, 'workspace' => workspace_client } + ) + + _stdout, _stderr, status = run_cli('workspace', 'request', 'get', '/api-reference', '--json') + + assert_equal 1, status + assert_empty workspace_client.requests + end + def test_pixel_logout_revokes_the_remote_token_then_deletes_local_keychain_value credentials = FakeCredentials.new pixel_client = FakeClient.new( diff --git a/test/cli_help_test.rb b/test/cli_help_test.rb index 09120ec..5ed7497 100644 --- a/test/cli_help_test.rb +++ b/test/cli_help_test.rb @@ -16,5 +16,6 @@ def test_top_level_help_lists_service_namespaces_and_doctor assert_includes stdout, 'pages' assert_includes stdout, 'pixel' assert_includes stdout, 'time-off' + assert_includes stdout, 'workspace' end end diff --git a/test/services_test.rb b/test/services_test.rb index 430f672..d58963b 100644 --- a/test/services_test.rb +++ b/test/services_test.rb @@ -50,6 +50,18 @@ def test_pages_contract_uses_a_service_specific_api_key_and_identity_endpoint assert_equal 'com.feedmob.fm.pages', service.keychain_service end + def test_workspace_contract_uses_the_internal_api_identity_endpoint + service = FeedMob::CLI::Services.fetch('workspace', env: {}) + + assert_equal 'FeedMob Workspace', service.label + assert_equal 'https://admin.feedmob.com', service.base_url + assert_equal 'FEEDMOB_WORKSPACE_TOKEN', service.token_env + assert_equal 'fmapat_', service.token_prefix + assert_equal '/api/v1/me', service.identity_path + assert_nil service.revoke_path + assert_equal 'com.feedmob.fm.workspace', service.keychain_service + end + def test_https_base_url_environment_override_is_normalized service = FeedMob::CLI::Services.fetch( 'pixel',