insightfulpipe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Refactored the CLI into modular command registration under `src/cli/`
6
+ - Split shared CLI helpers into focused modules for errors, input, options, and query-context workflows
7
+ - Aligned command names with the server surface using `query_data`, `execute_action`, and `query_contexts`
8
+ - Added workspace, brand, prompt, doctor, and discovery commands
9
+ - Added environment variable overrides and configurable request timeouts
10
+ - Hardened API error handling for invalid URLs, transport failures, request timeouts, and HTML error pages
11
+ - Added automated tests for routing, validation, confirmation behavior, JSON output, health checks, and timeout handling
12
+ - Added opt-in live smoke tests and repository documentation
13
+ - Added a CI workflow for automated test and package verification
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) InsightfulPipe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # InsightfulPipe CLI
2
+
3
+ Terminal access to InsightfulPipe raw APIs.
4
+
5
+ Recommended workflow:
6
+
7
+ 1. Discover accounts and metadata with `query_contexts`
8
+ 2. Inspect supported actions with `query_contexts available_actions`
9
+ 3. Inspect exact request bodies with `query_contexts actions_details`
10
+ 4. Execute read operations with `query_data`
11
+ 5. Execute write operations with `execute_action`
12
+
13
+ ## Requirements
14
+
15
+ - Node.js 18+
16
+ - An InsightfulPipe API token starting with `ip_sk_`
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install
22
+ ```
23
+
24
+ Run locally with:
25
+
26
+ ```bash
27
+ node bin/cli.js --help
28
+ ```
29
+
30
+ ## Authentication
31
+
32
+ Store a token in `~/.insightfulpipe/config.json`:
33
+
34
+ ```bash
35
+ node bin/cli.js auth
36
+ ```
37
+
38
+ You can also pipe the token instead of typing it interactively:
39
+
40
+ ```bash
41
+ printf '%s\n' "$INSIGHTFULPIPE_TOKEN" | node bin/cli.js auth
42
+ ```
43
+
44
+ The CLI also reads these environment variables:
45
+
46
+ - `INSIGHTFULPIPE_TOKEN`
47
+ - `INSIGHTFULPIPE_API_URL`
48
+ - `INSIGHTFULPIPE_TIMEOUT_MS`
49
+
50
+ ## Core Commands
51
+
52
+ ### Discovery
53
+
54
+ ```bash
55
+ node bin/cli.js whoami
56
+ node bin/cli.js platforms
57
+ node bin/cli.js workspaces
58
+ node bin/cli.js accounts --platform google-analytics
59
+ node bin/cli.js sources --platform google-search-console
60
+ node bin/cli.js brands --workspace 34
61
+ ```
62
+
63
+ ### Query Workflow
64
+
65
+ ```bash
66
+ node bin/cli.js query_contexts accounts
67
+ node bin/cli.js query_contexts available_actions --platform google-analytics
68
+ node bin/cli.js query_contexts actions_details --platform google-analytics --actions get_report
69
+ node bin/cli.js query_data google-analytics --body '{"action":"get_report","workspace_id":34,"brand_id":343,"property_id":"510157516","dimensions":["date"],"metrics":["sessions"],"start_date":"2026-03-01","end_date":"2026-03-07"}'
70
+ ```
71
+
72
+ ### Write operations
73
+
74
+ `execute_action` is the canonical write command. The shorter `action` alias is still available.
75
+
76
+ ```bash
77
+ node bin/cli.js execute_action facebook-pages --file payload.json
78
+ ```
79
+
80
+ In non-interactive environments, pass `--yes` to confirm explicitly:
81
+
82
+ ```bash
83
+ node bin/cli.js execute_action facebook-pages --file payload.json --yes
84
+ ```
85
+
86
+ ### Operational Checks
87
+
88
+ ```bash
89
+ node bin/cli.js doctor
90
+ node bin/cli.js --json doctor
91
+ node bin/cli.js --json config
92
+ ```
93
+
94
+ ### Prompts
95
+
96
+ ```bash
97
+ node bin/cli.js prompts google-ads
98
+ node bin/cli.js prompts google-ads --id 12
99
+ node bin/cli.js prompts google-ads --custom --workspace 34 --id 18
100
+ ```
101
+
102
+ ## Configuration
103
+
104
+ Show the current config:
105
+
106
+ ```bash
107
+ node bin/cli.js config
108
+ ```
109
+
110
+ Set a custom API base URL:
111
+
112
+ ```bash
113
+ node bin/cli.js config --set-url https://app.insightfulpipe.com
114
+ ```
115
+
116
+ Local HTTP endpoints are allowed only with `--allow-insecure`.
117
+
118
+ Set a request timeout:
119
+
120
+ ```bash
121
+ node bin/cli.js config --set-timeout 15000
122
+ ```
123
+
124
+ Use `--json` with discovery, configuration, and health commands when you want machine-readable output.
125
+
126
+ ## Testing
127
+
128
+ Run the local test suite:
129
+
130
+ ```bash
131
+ npm test
132
+ ```
133
+
134
+ Run opt-in live smoke tests against the real backend:
135
+
136
+ ```bash
137
+ INSIGHTFULPIPE_LIVE_TOKEN=ip_sk_xxx npm run test:live
138
+ ```
139
+
140
+ The live smoke test creates an isolated temporary home directory and does not overwrite your real `~/.insightfulpipe` config.
package/SECURITY.md ADDED
@@ -0,0 +1,23 @@
1
+ # Security Policy
2
+
3
+ ## Supported Scope
4
+
5
+ This repository contains the local CLI only. Backend authorization, platform permissions, and data access are enforced by the InsightfulPipe API.
6
+
7
+ ## Reporting a Vulnerability
8
+
9
+ Report security issues privately to the InsightfulPipe team. Do not open public issues for token leakage, privilege escalation, SSRF, authentication bypass, or sensitive data exposure.
10
+
11
+ ## Token Handling
12
+
13
+ - API tokens are stored in `~/.insightfulpipe/config.json`
14
+ - `INSIGHTFULPIPE_TOKEN` can be used instead of storing a token on disk
15
+ - Interactive `auth` input is hidden from terminal echo
16
+ - The CLI masks stored tokens in `config` output
17
+ - Non-HTTPS API base URLs require explicit `--allow-insecure`
18
+
19
+ ## Operational Guidance
20
+
21
+ - Prefer piping tokens through stdin in automation instead of placing them in shell history
22
+ - Use a temporary `HOME` when running smoke tests against production
23
+ - Treat `execute_action` as a privileged operation and require explicit confirmation in non-interactive flows
package/bin/cli.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { buildProgram } from '../src/cli/program.js';
4
+
5
+ const program = buildProgram();
6
+
7
+ try {
8
+ await program.parseAsync();
9
+ } catch (error) {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ console.error(`Error: ${message}`);
12
+ process.exit(1);
13
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "insightfulpipe",
3
+ "version": "0.1.0",
4
+ "description": "InsightfulPipe CLI — query your marketing data from the terminal",
5
+ "bin": {
6
+ "insightfulpipe": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "src/",
11
+ "README.md",
12
+ "CHANGELOG.md",
13
+ "SECURITY.md",
14
+ "LICENSE"
15
+ ],
16
+ "type": "module",
17
+ "scripts": {
18
+ "start": "node bin/cli.js",
19
+ "test": "node --test",
20
+ "test:unit": "node --test test/cli.test.js",
21
+ "test:live": "node --test test/live-smoke.test.js"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "keywords": [
27
+ "insightfulpipe",
28
+ "marketing",
29
+ "analytics",
30
+ "cli",
31
+ "mcp"
32
+ ],
33
+ "license": "MIT",
34
+ "dependencies": {
35
+ "@napi-rs/keyring": "^1.2.0",
36
+ "commander": "^13.0.0"
37
+ }
38
+ }
package/src/api.js ADDED
@@ -0,0 +1,217 @@
1
+ import { getApiUrl, getRequestTimeoutMs, getToken } from './config.js';
2
+
3
+ let _insecureWarned = false;
4
+
5
+ function warnIfInsecure() {
6
+ if (_insecureWarned) return;
7
+ const url = getApiUrl();
8
+ if (!url.startsWith('https://')) {
9
+ console.error('Warning: API URL is not using HTTPS. Traffic including your token is unencrypted.');
10
+ _insecureWarned = true;
11
+ }
12
+ }
13
+
14
+ const PLATFORM_SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
15
+
16
+ export function validatePlatformSlug(slug) {
17
+ if (!PLATFORM_SLUG_RE.test(slug) || slug.includes('--')) {
18
+ console.error(`Error: Invalid platform slug "${slug}". Use lowercase letters, numbers, and hyphens only.`);
19
+ process.exit(1);
20
+ }
21
+ }
22
+
23
+ function requireAuth() {
24
+ const token = getToken();
25
+ if (!token) {
26
+ console.error('Error: Not authenticated. Run: insightfulpipe auth');
27
+ process.exit(1);
28
+ }
29
+ return token;
30
+ }
31
+
32
+ function buildApiUrl(path) {
33
+ const baseUrl = getApiUrl();
34
+
35
+ let base;
36
+ try {
37
+ base = new URL(baseUrl);
38
+ } catch {
39
+ throw new Error(`Configured API URL is invalid: ${baseUrl}`);
40
+ }
41
+
42
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
43
+ return new URL(`/api/raw${normalizedPath}`, base).toString();
44
+ }
45
+
46
+ function looksLikeHtml(text) {
47
+ const trimmed = text.trim().toLowerCase();
48
+ return trimmed.startsWith('<!doctype html') || trimmed.startsWith('<html');
49
+ }
50
+
51
+ function summarizeTextError(text, res) {
52
+ if (looksLikeHtml(text)) {
53
+ const titleMatch = text.match(/<title[^>]*>([^<]+)<\/title>/i);
54
+ const headingMatch = text.match(/<h1[^>]*>([^<]+)<\/h1>/i) || text.match(/<h2[^>]*>([^<]+)<\/h2>/i);
55
+ const summary = titleMatch?.[1]?.trim() || headingMatch?.[1]?.trim() || `HTTP ${res.status}`;
56
+
57
+ return {
58
+ error: true,
59
+ status: res.status,
60
+ message: `Server returned an HTML error page (${summary}).`,
61
+ };
62
+ }
63
+
64
+ return {
65
+ error: true,
66
+ status: res.status,
67
+ message: text,
68
+ };
69
+ }
70
+
71
+ async function parseJsonResponse(res) {
72
+ const text = await res.text();
73
+
74
+ if (!text.trim()) {
75
+ return {};
76
+ }
77
+
78
+ try {
79
+ return JSON.parse(text);
80
+ } catch {
81
+ if (res.ok) {
82
+ return {
83
+ error: true,
84
+ status: res.status,
85
+ message: 'Received invalid JSON from server.',
86
+ details: text,
87
+ };
88
+ }
89
+
90
+ return summarizeTextError(text, res);
91
+ }
92
+ }
93
+
94
+ async function apiRequest(method, path, body) {
95
+ warnIfInsecure();
96
+ const token = requireAuth();
97
+ const timeoutMs = getRequestTimeoutMs();
98
+
99
+ let url;
100
+ try {
101
+ url = buildApiUrl(path);
102
+ } catch (error) {
103
+ return {
104
+ error: true,
105
+ message: error.message,
106
+ };
107
+ }
108
+
109
+ const options = {
110
+ method,
111
+ headers: {
112
+ 'Authorization': `Bearer ${token}`,
113
+ 'Accept': 'application/json',
114
+ },
115
+ };
116
+
117
+ if (body !== undefined) {
118
+ options.headers['Content-Type'] = 'application/json';
119
+ options.body = JSON.stringify(body);
120
+ }
121
+
122
+ let res;
123
+ const controller = new AbortController();
124
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
125
+ try {
126
+ res = await fetch(url, { ...options, signal: controller.signal });
127
+ } catch (error) {
128
+ clearTimeout(timeout);
129
+ return {
130
+ error: true,
131
+ message:
132
+ error?.name === 'AbortError'
133
+ ? `Network request timed out after ${timeoutMs} ms`
134
+ : `Network request failed: ${error.message}`,
135
+ };
136
+ }
137
+ clearTimeout(timeout);
138
+
139
+ const data = await parseJsonResponse(res);
140
+
141
+ if (data && data.error) {
142
+ return data;
143
+ }
144
+
145
+ if (!res.ok) {
146
+ return {
147
+ error: true,
148
+ status: res.status,
149
+ ...(data && typeof data === 'object' ? data : { message: String(data) }),
150
+ };
151
+ }
152
+
153
+ return data;
154
+ }
155
+
156
+ export async function apiGet(path) {
157
+ return apiRequest('GET', path);
158
+ }
159
+
160
+ export async function apiPost(path, body) {
161
+ return apiRequest('POST', path, body);
162
+ }
163
+
164
+ export async function accountSummary() {
165
+ warnIfInsecure();
166
+ const token = requireAuth();
167
+ const timeoutMs = getRequestTimeoutMs();
168
+ let url;
169
+ try {
170
+ url = new URL('/oauth/api/account-summary/', new URL(getApiUrl())).toString();
171
+ } catch {
172
+ return {
173
+ error: true,
174
+ message: `Configured API URL is invalid: ${getApiUrl()}`,
175
+ };
176
+ }
177
+
178
+ let res;
179
+ const controller = new AbortController();
180
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
181
+ try {
182
+ res = await fetch(url, {
183
+ method: 'GET',
184
+ headers: {
185
+ 'Authorization': `Bearer ${token}`,
186
+ 'Accept': 'application/json',
187
+ },
188
+ signal: controller.signal,
189
+ });
190
+ } catch (error) {
191
+ clearTimeout(timeout);
192
+ return {
193
+ error: true,
194
+ message:
195
+ error?.name === 'AbortError'
196
+ ? `Network request timed out after ${timeoutMs} ms`
197
+ : `Network request failed: ${error.message}`,
198
+ };
199
+ }
200
+ clearTimeout(timeout);
201
+
202
+ const data = await parseJsonResponse(res);
203
+
204
+ if (data && data.error) {
205
+ return data;
206
+ }
207
+
208
+ if (!res.ok) {
209
+ return {
210
+ error: true,
211
+ status: res.status,
212
+ ...(data && typeof data === 'object' ? data : { message: String(data) }),
213
+ };
214
+ }
215
+
216
+ return data;
217
+ }
@@ -0,0 +1,30 @@
1
+ import { getConfigSnapshot, saveConfig } from '../../config.js';
2
+ import { fail } from '../errors.js';
3
+ import { promptSecret } from '../input.js';
4
+
5
+ export function registerAuthCommand(program) {
6
+ program
7
+ .command('auth')
8
+ .description('Save your InsightfulPipe API key')
9
+ .option('--insecure-storage', 'Save token to config file instead of OS keyring')
10
+ .action(async (opts) => {
11
+ const token = await promptSecret('Enter your API token: ');
12
+
13
+ if (!token) {
14
+ fail('No token provided.');
15
+ }
16
+
17
+ if (!token.startsWith('ip_sk_')) {
18
+ fail('Token must start with ip_sk_.');
19
+ }
20
+
21
+ const config = { ...getConfigSnapshot().rawValues, token };
22
+ const { tokenInKeyring } = saveConfig(config, { insecure: opts.insecureStorage });
23
+
24
+ if (tokenInKeyring) {
25
+ console.log('Authenticated successfully. Token saved to OS keyring.');
26
+ } else {
27
+ console.log('Authenticated successfully. Token saved to config file.');
28
+ }
29
+ });
30
+ }
@@ -0,0 +1,62 @@
1
+ import { getConfigSnapshot, normalizeTimeoutMs, saveConfig } from '../../config.js';
2
+ import { printJson, wantsJson } from '../../output.js';
3
+ import { validateConfiguredUrl } from '../options.js';
4
+
5
+ export function registerConfigCommand(program) {
6
+ program
7
+ .command('config')
8
+ .description('Show current configuration')
9
+ .option('--set-url <url>', 'Set API URL')
10
+ .option('--set-timeout <ms>', 'Set request timeout in milliseconds')
11
+ .option('--allow-insecure', 'Allow http:// URLs for local development only')
12
+ .action((opts, command) => {
13
+ const snapshot = getConfigSnapshot();
14
+ const nextConfig = { ...snapshot.rawValues };
15
+
16
+ if (opts.setUrl) {
17
+ nextConfig.api_url = validateConfiguredUrl(opts.setUrl, opts.allowInsecure);
18
+ }
19
+
20
+ if (opts.setTimeout) {
21
+ nextConfig.timeout_ms = normalizeTimeoutMs(opts.setTimeout, '--set-timeout');
22
+ }
23
+
24
+ if (opts.setUrl || opts.setTimeout) {
25
+ saveConfig(nextConfig);
26
+ if (wantsJson(command)) {
27
+ printJson({
28
+ updated: true,
29
+ values: {
30
+ api_url: nextConfig.api_url ?? snapshot.values.api_url,
31
+ timeout_ms: nextConfig.timeout_ms ?? snapshot.values.timeout_ms,
32
+ },
33
+ });
34
+ return;
35
+ }
36
+
37
+ console.log(`API URL: ${nextConfig.api_url ?? snapshot.values.api_url}`);
38
+ console.log(`Timeout: ${nextConfig.timeout_ms ?? snapshot.values.timeout_ms} ms`);
39
+ return;
40
+ }
41
+
42
+ const output = {
43
+ config_file: snapshot.configFile,
44
+ values: {
45
+ api_url: snapshot.values.api_url,
46
+ token: snapshot.values.token ? `${snapshot.values.token.slice(0, 10)}...` : null,
47
+ timeout_ms: snapshot.values.timeout_ms,
48
+ },
49
+ sources: snapshot.sources,
50
+ };
51
+
52
+ if (wantsJson(command)) {
53
+ printJson(output);
54
+ return;
55
+ }
56
+
57
+ console.log(`Config file: ${output.config_file}`);
58
+ console.log(`API URL: ${output.values.api_url} (${output.sources.api_url})`);
59
+ console.log(`Token: ${output.values.token || 'Not set'} (${output.sources.token})`);
60
+ console.log(`Timeout: ${output.values.timeout_ms} ms (${output.sources.timeout_ms})`);
61
+ });
62
+ }