diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index fad465d..248a176 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -77,7 +77,14 @@ jobs: - name: Publish @graspful/shared if: github.event.inputs.dry_run != 'true' - run: cd packages/shared && npm publish --access public || echo "shared already published at this version" + run: | + cd packages/shared + VERSION=$(node -p "require('./package.json').version") + if npm view "@graspful/shared@$VERSION" version >/dev/null 2>&1; then + echo "@graspful/shared@$VERSION is already published" + else + npm publish --access public + fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -91,13 +98,27 @@ jobs: - name: Publish @graspful/cli if: (steps.target.outputs.target == 'all' || steps.target.outputs.target == 'cli') && github.event.inputs.dry_run != 'true' - run: cd packages/cli && npm publish --access public || echo "cli already published at this version" + run: | + cd packages/cli + VERSION=$(node -p "require('./package.json').version") + if npm view "@graspful/cli@$VERSION" version >/dev/null 2>&1; then + echo "@graspful/cli@$VERSION is already published" + else + npm publish --access public + fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish @graspful/mcp if: (steps.target.outputs.target == 'all' || steps.target.outputs.target == 'mcp') && github.event.inputs.dry_run != 'true' - run: cd packages/mcp && npm publish --access public || echo "mcp already published at this version" + run: | + cd packages/mcp + VERSION=$(node -p "require('./package.json').version") + if npm view "@graspful/mcp@$VERSION" version >/dev/null 2>&1; then + echo "@graspful/mcp@$VERSION is already published" + else + npm publish --access public + fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/bun.lock b/bun.lock index aab3d61..f858ad7 100644 --- a/bun.lock +++ b/bun.lock @@ -141,7 +141,7 @@ }, "packages/cli": { "name": "@graspful/cli", - "version": "0.2.6", + "version": "0.2.7", "bin": { "graspful": "./dist/index.js", }, @@ -161,7 +161,7 @@ }, "packages/mcp": { "name": "@graspful/mcp", - "version": "0.2.4", + "version": "0.2.5", "bin": { "graspful-mcp": "./dist/index.js", }, diff --git a/packages/cli/README.md b/packages/cli/README.md index 66d394b..1c48ebe 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -329,6 +329,10 @@ graspful import course.yaml --org my-org |----------|-------------| | `GRASPFUL_API_KEY` | API key for authenticated commands (`import`, `publish`) | | `GRASPFUL_API_URL` | API base URL (default: `https://api.graspful.ai`) | +| `GRASPFUL_USER_ID` | Optional Graspful user ID for analytics identity continuity | +| `GRASPFUL_TELEMETRY_DISABLED` | Set to `1` to disable anonymous product analytics | + +The CLI sends command usage and outcome metadata to help improve Graspful. It never sends API keys or YAML course bodies. Set `GRASPFUL_TELEMETRY_DISABLED=1` to disable this data collection. ## Links diff --git a/packages/cli/package.json b/packages/cli/package.json index a9cce53..31ce850 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@graspful/cli", - "version": "0.2.6", + "version": "0.2.7", "description": "Create adaptive learning courses from YAML. CLI and MCP server for AI agents.", "keywords": ["course", "learning", "adaptive", "mcp", "mcp-server", "ai", "ai-agent", "agent", "cli", "education", "edtech", "knowledge-graph", "spaced-repetition", "course-creation", "yaml", "adaptive-learning", "lms"], "repository": { diff --git a/packages/cli/src/commands/__tests__/login.test.ts b/packages/cli/src/commands/__tests__/login.test.ts index 6cdbbc7..57c4d08 100644 --- a/packages/cli/src/commands/__tests__/login.test.ts +++ b/packages/cli/src/commands/__tests__/login.test.ts @@ -116,6 +116,7 @@ describe('graspful login', () => { expect(writeFileSyncSpy).toHaveBeenCalledTimes(1); const savedContent = JSON.parse(writeFileSyncSpy.mock.calls[0][1] as string); expect(savedContent.apiKey).toBe('gsk_browser_flow_key'); + expect(savedContent.userId).toBe('user-123'); expect(savedContent.baseUrl).toBe('http://localhost:3000'); }); }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 8ae2cff..c106b52 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -45,7 +45,7 @@ function detectEditors(): Editor[] { return editors; } -function writeMcpConfig(configPath: string, apiKey: string): void { +function writeMcpConfig(configPath: string, apiKey: string, userId?: string): void { const dir = path.dirname(configPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); @@ -64,7 +64,10 @@ function writeMcpConfig(configPath: string, apiKey: string): void { mcpServers['graspful'] = { command: 'npx', args: ['-y', '@graspful/mcp'], - env: { GRASPFUL_API_KEY: apiKey }, + env: { + GRASPFUL_API_KEY: apiKey, + ...(userId ? { GRASPFUL_USER_ID: userId } : {}), + }, }; existing.mcpServers = mcpServers; @@ -91,7 +94,7 @@ export function registerInitCommand(program: Command) { // Still configure MCP if requested if (opts.mcp) { - configureMcp(existingCreds.apiKey); + configureMcp(existingCreds.apiKey, existingCreds.userId); } output( @@ -120,7 +123,7 @@ export function registerInitCommand(program: Command) { // ── Configure MCP ─────────────────────────────────────────────── if (opts.mcp) { - configureMcp(data.apiKey); + configureMcp(data.apiKey, data.userId); } output( @@ -147,7 +150,7 @@ export function registerInitCommand(program: Command) { }); } -function configureMcp(apiKey: string): void { +function configureMcp(apiKey: string, userId?: string): void { const editors = detectEditors(); if (editors.length === 0) { @@ -157,7 +160,10 @@ function configureMcp(apiKey: string): void { graspful: { command: 'npx', args: ['-y', '@graspful/mcp'], - env: { GRASPFUL_API_KEY: apiKey }, + env: { + GRASPFUL_API_KEY: apiKey, + ...(userId ? { GRASPFUL_USER_ID: userId } : {}), + }, }, }, }, null, 2)); @@ -165,7 +171,7 @@ function configureMcp(apiKey: string): void { } for (const editor of editors) { - writeMcpConfig(editor.configPath, apiKey); + writeMcpConfig(editor.configPath, apiKey, userId); cliCapture('cli initialized', { editor: editor.name }); console.log(`\nMCP configured for ${editor.name}: ${editor.configPath}`); } diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index a7ddd7f..7453703 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -53,7 +53,7 @@ export function registerLoginCommand(program: Command) { userId: string; }; - saveApiKeyCredentials(apiKey, baseUrl); + saveApiKeyCredentials(apiKey, baseUrl, userId); cliCapture('cli logged in', { method: 'email-password' }); output( { authenticated: true, baseUrl, tokenType: 'apiKey', orgSlug, userId }, @@ -70,6 +70,7 @@ export function registerLoginCommand(program: Command) { noBrowser: opts.browser === false, }); + cliCapture('cli logged in', { method: 'browser-auth' }); output( { authenticated: true, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e69acb0..e36ef44 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,7 +20,7 @@ const program = new Command(); program .name('graspful') .description('Create adaptive learning courses from YAML') - .version('0.1.0') + .version('0.2.7') .option('--format ', 'Output format: human or json', 'human') .hook('preAction', (thisCommand) => { const opts = thisCommand.opts(); diff --git a/packages/cli/src/lib/__tests__/analytics.test.ts b/packages/cli/src/lib/__tests__/analytics.test.ts index 8f9c9ff..c28c44b 100644 --- a/packages/cli/src/lib/__tests__/analytics.test.ts +++ b/packages/cli/src/lib/__tests__/analytics.test.ts @@ -48,6 +48,15 @@ describe('cliDistinctId', () => { ); }); + test('uses the stored user ID before hashing stored credentials', () => { + delete process.env.GRASPFUL_USER_ID; + delete process.env.GRASPFUL_API_KEY; + + expect(cliDistinctId({ apiKey: 'gsk_stored', userId: 'user-stored' })).toBe( + 'user-stored', + ); + }); + test('uses a stable identifier for the current anonymous process', () => { delete process.env.GRASPFUL_USER_ID; delete process.env.GRASPFUL_API_KEY; diff --git a/packages/cli/src/lib/analytics.ts b/packages/cli/src/lib/analytics.ts index 64d251b..c6c2476 100644 --- a/packages/cli/src/lib/analytics.ts +++ b/packages/cli/src/lib/analytics.ts @@ -6,7 +6,15 @@ import * as path from 'node:path'; import type { Credentials } from './auth'; import { resolveCredentials } from './auth'; -const posthogKey = process.env.POSTHOG_API_KEY || process.env.NEXT_PUBLIC_POSTHOG_KEY; +const DEFAULT_POSTHOG_KEY = 'phc_ahQLCJsOBzeuro1yDeurs1a3xx07pIreJWeXG9T4d4'; +const telemetryDisabled = + process.env.GRASPFUL_TELEMETRY_DISABLED === '1' || + process.env.NODE_ENV === 'test'; +const posthogKey = telemetryDisabled + ? null + : process.env.POSTHOG_API_KEY || + process.env.NEXT_PUBLIC_POSTHOG_KEY || + DEFAULT_POSTHOG_KEY; let client: PostHog | null = null; let anonymousDistinctId: string | null = null; @@ -53,7 +61,7 @@ function getOrCreateAnonymousDistinctId(): string { } export function cliDistinctId( - credentials?: Pick, + credentials?: Pick, fallbackAnonymousId?: string, ): string { if (process.env.GRASPFUL_USER_ID) { @@ -61,6 +69,10 @@ export function cliDistinctId( } const resolvedCredentials = credentials ?? resolveCredentials(); + if (resolvedCredentials.userId) { + return resolvedCredentials.userId; + } + const credential = process.env.GRASPFUL_API_KEY ?? resolvedCredentials.apiKey ?? diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 708ba30..c333c54 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -5,6 +5,7 @@ import * as os from 'os'; export interface Credentials { apiKey?: string; jwt?: string; + userId?: string; baseUrl: string; } @@ -20,7 +21,7 @@ export function resolveCredentials(): Credentials { // 1. API key (agent mode) const apiKey = process.env.GRASPFUL_API_KEY; if (apiKey) { - return { apiKey, baseUrl }; + return { apiKey, userId: process.env.GRASPFUL_USER_ID, baseUrl }; } // 2. Stored credentials (interactive or registered) @@ -28,9 +29,17 @@ export function resolveCredentials(): Credentials { try { const stored = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8')); if (stored.apiKey) { - return { apiKey: stored.apiKey, baseUrl: stored.baseUrl || baseUrl }; + return { + apiKey: stored.apiKey, + userId: stored.userId, + baseUrl: stored.baseUrl || baseUrl, + }; } - return { jwt: stored.jwt, baseUrl: stored.baseUrl || baseUrl }; + return { + jwt: stored.jwt, + userId: stored.userId, + baseUrl: stored.baseUrl || baseUrl, + }; } catch { // Invalid file } @@ -51,14 +60,14 @@ export function saveCredentials(jwt: string, baseUrl?: string): void { ); } -export function saveApiKeyCredentials(apiKey: string, baseUrl?: string): void { +export function saveApiKeyCredentials(apiKey: string, baseUrl?: string, userId?: string): void { const dir = path.dirname(CREDENTIALS_PATH); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } fs.writeFileSync( CREDENTIALS_PATH, - JSON.stringify({ apiKey, baseUrl: baseUrl || getBaseUrl() }, null, 2), + JSON.stringify({ apiKey, userId, baseUrl: baseUrl || getBaseUrl() }, null, 2), { mode: 0o600 }, ); } diff --git a/packages/cli/src/lib/browser-auth.ts b/packages/cli/src/lib/browser-auth.ts index 6323a00..05fe367 100644 --- a/packages/cli/src/lib/browser-auth.ts +++ b/packages/cli/src/lib/browser-auth.ts @@ -156,7 +156,7 @@ export async function runBrowserAuthFlow(options: BrowserAuthOptions): Promise