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
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -33,6 +33,10 @@ fm pages share enable <page-id> [--rotate]
fm pages share revoke <page-id>
fm pages asset upload <image-file>
fm pages request get <path>
fm workspace auth login [--token-stdin]
fm workspace auth status
fm workspace auth logout
fm workspace request get <api-v1-path>
```

## Installation
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions lib/feedmob/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions lib/feedmob/cli/commands/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions lib/feedmob/cli/commands/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions lib/feedmob/cli/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 38 additions & 0 deletions lib/feedmob/cli/service_definitions.rb
Original file line number Diff line number Diff line change
@@ -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
48 changes: 1 addition & 47 deletions lib/feedmob/cli/services.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions test/cli_commands_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions test/cli_help_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions test/services_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading