@socialseal/cli 0.1.12 → 0.1.13

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +339 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@socialseal/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SocialSeal CLI (non-interactive)",
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { spawn } from 'node:child_process';
3
4
  import fs from 'node:fs';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
@@ -8,6 +9,7 @@ import WebSocket from 'ws';
8
9
 
9
10
  const DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.config', 'socialseal', 'config.json');
10
11
  const DEFAULT_API_BASE = 'https://api.socialseal.co';
12
+ const DEFAULT_WEB_BASE = 'https://app.socialseal.co';
11
13
  const CLI_KEY_HEADER = 'X-CLI-Key';
12
14
  const WORKSPACE_HEADER = 'X-Workspace-Id';
13
15
  const DEFAULT_TIMEOUT_MS = 300000;
@@ -1018,9 +1020,36 @@ function saveConfig(config) {
1018
1020
  Object.entries(config || {}).filter(([, value]) => value !== undefined),
1019
1021
  );
1020
1022
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
1021
- fs.writeFileSync(configPath, `${JSON.stringify(normalizedConfig, null, 2)}\n`);
1023
+ fs.writeFileSync(configPath, `${JSON.stringify(normalizedConfig, null, 2)}\n`, {
1024
+ mode: 0o600,
1025
+ });
1026
+ fs.chmodSync(configPath, 0o600);
1027
+ }
1028
+
1029
+ function assertConfigWritable() {
1030
+ const configPath = getConfigPath();
1031
+ const configDir = path.dirname(configPath);
1032
+ fs.mkdirSync(configDir, { recursive: true });
1033
+ const probePath = path.join(configDir, `.socialseal-write-test-${process.pid}-${Date.now()}`);
1034
+ try {
1035
+ fs.writeFileSync(probePath, '', { mode: 0o600 });
1036
+ fs.unlinkSync(probePath);
1037
+ } catch (error) {
1038
+ try {
1039
+ if (fs.existsSync(probePath)) fs.unlinkSync(probePath);
1040
+ } catch {
1041
+ // best effort cleanup only
1042
+ }
1043
+ throw new CliError(`Cannot write SocialSeal config at ${configPath}.`, {
1044
+ code: 'CONFIG_NOT_WRITABLE',
1045
+ exitCode: EXIT_CODES.USAGE,
1046
+ hint: 'Set SOCIALSEAL_CONFIG to a writable path, or set SOCIALSEAL_API_KEY manually.',
1047
+ details: error?.message || String(error),
1048
+ });
1049
+ }
1022
1050
  }
1023
1051
 
1052
+
1024
1053
  function resolveApiKey(opts, config) {
1025
1054
  return opts.apiKey || process.env.SOCIALSEAL_API_KEY || config.apiKey;
1026
1055
  }
@@ -1037,6 +1066,10 @@ function resolveSupabaseUrl(opts, config) {
1037
1066
  return opts.supabaseUrl || process.env.SOCIALSEAL_SUPABASE_URL || config.supabaseUrl;
1038
1067
  }
1039
1068
 
1069
+ function resolveWebBase(opts = {}, config = {}) {
1070
+ return opts.webBase || process.env.SOCIALSEAL_WEB_BASE || config.webBase || DEFAULT_WEB_BASE;
1071
+ }
1072
+
1040
1073
  function resolveWorkspaceSelection(opts, config) {
1041
1074
  if (typeof opts.workspaceId === 'string' && opts.workspaceId.trim().length > 0) {
1042
1075
  return { workspaceId: opts.workspaceId.trim(), source: 'flag' };
@@ -3149,7 +3182,9 @@ function buildStatusHint(status, context = {}) {
3149
3182
  switch (status) {
3150
3183
  case 401:
3151
3184
  case 403:
3152
- return 'Check your CLI key and workspace access.';
3185
+ return 'Authentication failed. Run `socialseal login`, or check your CLI key and workspace access.';
3186
+ case 402:
3187
+ return 'Your free credits or quota may be exhausted. Run `socialseal billing` to open billing and credits options.';
3153
3188
  case 404:
3154
3189
  if (context.functionName) {
3155
3190
  if (isLocallyDisabledByDefaultFunction(context.functionName)) {
@@ -3163,6 +3198,9 @@ function buildStatusHint(status, context = {}) {
3163
3198
  case 422:
3164
3199
  return 'Validation error. Review the JSON payload schema. For tracking/group tools, prefer the CLI action aliases or the documented REST semantics.';
3165
3200
  default:
3201
+ if (context.billingRelated) {
3202
+ return 'Run `socialseal billing` to open billing and credits options.';
3203
+ }
3166
3204
  return null;
3167
3205
  }
3168
3206
  }
@@ -3189,7 +3227,9 @@ async function buildHttpError(res, context = {}) {
3189
3227
 
3190
3228
  const label = context.label || 'Request';
3191
3229
  const statusText = res.statusText ? ` ${res.statusText}` : '';
3192
- const hint = context.hint || buildStatusHint(status, context);
3230
+ const serializedDetails = typeof details === 'string' ? details : JSON.stringify(details);
3231
+ const billingRelated = /\b(credit|credits|quota|billing|entitlement|payment|plan)\b/i.test(serializedDetails || '');
3232
+ const hint = context.hint || buildStatusHint(status, { ...context, billingRelated });
3193
3233
 
3194
3234
  return new CliError(`${label} failed: ${status}${statusText}`.trim(), {
3195
3235
  code: 'HTTP_ERROR',
@@ -3673,9 +3713,10 @@ function coerceCliError(err, fallbackMessage = 'Command failed') {
3673
3713
  function requireApiKey(opts, config) {
3674
3714
  const apiKey = resolveApiKey(opts, config);
3675
3715
  if (!apiKey) {
3676
- throw new CliError('Missing API key. Set SOCIALSEAL_API_KEY or --api-key.', {
3716
+ throw new CliError('Missing API key. Run `socialseal login` to connect this CLI.', {
3677
3717
  code: 'MISSING_API_KEY',
3678
- exitCode: EXIT_CODES.USAGE,
3718
+ exitCode: EXIT_CODES.AUTH,
3719
+ hint: 'Run `socialseal login`, or set SOCIALSEAL_API_KEY if you already have a key.',
3679
3720
  });
3680
3721
  }
3681
3722
  return apiKey;
@@ -5229,6 +5270,258 @@ async function handleVideoQueueAnalysis(opts) {
5229
5270
  emitJsonOutput(payload, opts.pretty);
5230
5271
  }
5231
5272
 
5273
+ function maskApiKey(apiKey) {
5274
+ const key = typeof apiKey === 'string' ? apiKey.trim() : '';
5275
+ if (!key) return null;
5276
+ return `…${key.slice(-6)}`;
5277
+ }
5278
+
5279
+ function openBrowser(url, onError) {
5280
+ const platform = process.platform;
5281
+ const command = platform === 'darwin'
5282
+ ? 'open'
5283
+ : platform === 'win32'
5284
+ ? 'cmd'
5285
+ : 'xdg-open';
5286
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
5287
+ const child = spawn(command, args, {
5288
+ detached: true,
5289
+ stdio: 'ignore',
5290
+ });
5291
+ child.on('error', (error) => {
5292
+ if (typeof onError === 'function') onError(error);
5293
+ });
5294
+ child.unref();
5295
+ }
5296
+
5297
+ async function callPublicApi({ apiBase, path: requestPath, method = 'POST', body, timeoutMs }) {
5298
+ if (!apiBase) {
5299
+ throw new CliError('Missing API base. Set SOCIALSEAL_API_BASE or --api-base.', {
5300
+ code: 'MISSING_API_BASE',
5301
+ exitCode: EXIT_CODES.USAGE,
5302
+ });
5303
+ }
5304
+ const normalizedMethod = normalizeMethod(method);
5305
+ const url = `${apiBase.replace(/\/$/, '')}${requestPath.startsWith('/') ? requestPath : `/${requestPath}`}`;
5306
+ const hasBody = body !== undefined && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD';
5307
+ return fetchWithTimeout(url, {
5308
+ method: normalizedMethod,
5309
+ headers: {
5310
+ Accept: 'application/json',
5311
+ ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
5312
+ },
5313
+ body: hasBody ? JSON.stringify(body ?? {}) : undefined,
5314
+ }, timeoutMs ?? DEFAULT_TIMEOUT_MS);
5315
+ }
5316
+
5317
+ async function readJsonResponse(res, label) {
5318
+ const contentType = res.headers.get('content-type') || '';
5319
+ if (!contentType.includes('application/json')) {
5320
+ throw new CliError(`${label} returned a non-JSON response.`, {
5321
+ code: 'INVALID_RESPONSE',
5322
+ exitCode: EXIT_CODES.SERVER,
5323
+ });
5324
+ }
5325
+ return res.json();
5326
+ }
5327
+
5328
+ async function handleLogin(opts) {
5329
+ const config = loadConfig();
5330
+ const apiBase = resolveApiBase(opts, config) || DEFAULT_API_BASE;
5331
+ const timeoutMs = resolveTimeoutMs(opts, config);
5332
+ assertConfigWritable();
5333
+ const authorizeRes = await callPublicApi({
5334
+ apiBase,
5335
+ path: '/cli/device/authorize',
5336
+ body: {
5337
+ clientId: '@socialseal/cli',
5338
+ clientName: 'SocialSeal CLI',
5339
+ scopes: { cli: true },
5340
+ },
5341
+ timeoutMs,
5342
+ });
5343
+
5344
+ if (!authorizeRes.ok) {
5345
+ throw await buildHttpError(authorizeRes, { label: 'Device authorization start' });
5346
+ }
5347
+
5348
+ const authorizePayload = await readJsonResponse(authorizeRes, 'Device authorization start');
5349
+ const verificationUrl = authorizePayload.verification_uri_complete || authorizePayload.verification_uri;
5350
+ const deviceCode = authorizePayload.device_code;
5351
+ const userCode = authorizePayload.user_code;
5352
+ if (!verificationUrl || !deviceCode || !userCode) {
5353
+ throw new CliError('Device authorization start returned an incomplete response.', {
5354
+ code: 'INVALID_RESPONSE',
5355
+ exitCode: EXIT_CODES.SERVER,
5356
+ });
5357
+ }
5358
+
5359
+ if (!opts.json) {
5360
+ process.stdout.write(`[socialseal] Open this URL to approve login: ${verificationUrl}\n`);
5361
+ process.stdout.write(`[socialseal] Confirm code: ${userCode}\n`);
5362
+ }
5363
+
5364
+ if (opts.open !== false) {
5365
+ openBrowser(String(verificationUrl), (error) => {
5366
+ if (opts.verbose) {
5367
+ process.stderr.write(`[socialseal] Could not open browser automatically: ${error.message || error}\n`);
5368
+ }
5369
+ });
5370
+ }
5371
+
5372
+ const startedAt = Date.now();
5373
+ let intervalMs = Math.max(1000, Number(authorizePayload.interval || 5) * 1000);
5374
+ if (opts.pollInterval) {
5375
+ intervalMs = parseTimeoutMs(opts.pollInterval, { defaultValue: intervalMs, label: 'poll interval' });
5376
+ }
5377
+
5378
+ while (Date.now() - startedAt < timeoutMs) {
5379
+ await sleep(intervalMs);
5380
+ const tokenRes = await callPublicApi({
5381
+ apiBase,
5382
+ path: '/cli/device/token',
5383
+ body: { device_code: deviceCode },
5384
+ timeoutMs: Math.max(1000, timeoutMs - (Date.now() - startedAt)),
5385
+ });
5386
+ const tokenPayload = await readJsonResponse(tokenRes, 'Device token poll');
5387
+
5388
+ if (tokenRes.ok) {
5389
+ const apiKey = typeof tokenPayload.api_key === 'string' ? tokenPayload.api_key : '';
5390
+ if (!apiKey) {
5391
+ throw new CliError('Device token poll returned no API key.', {
5392
+ code: 'INVALID_RESPONSE',
5393
+ exitCode: EXIT_CODES.SERVER,
5394
+ });
5395
+ }
5396
+
5397
+ const workspaceId = typeof tokenPayload.workspace_id === 'string' ? tokenPayload.workspace_id : config.workspaceId;
5398
+ saveConfig({
5399
+ ...config,
5400
+ apiBase,
5401
+ apiKey,
5402
+ workspaceId,
5403
+ });
5404
+
5405
+ const payload = {
5406
+ success: true,
5407
+ apiBase,
5408
+ keySuffix: apiKey.slice(-6),
5409
+ key: maskApiKey(apiKey),
5410
+ workspaceId: workspaceId || null,
5411
+ configPath: getConfigPath(),
5412
+ };
5413
+ if (opts.json) {
5414
+ process.stdout.write(JSON.stringify(payload, null, opts.pretty ? 2 : 0) + '\n');
5415
+ return;
5416
+ }
5417
+
5418
+ process.stdout.write(`[socialseal] Login complete. Stored key ${maskApiKey(apiKey)} in ${getConfigPath()}\n`);
5419
+ if (workspaceId) {
5420
+ process.stdout.write(`[socialseal] Default workspace set to ${workspaceId}\n`);
5421
+ }
5422
+ return;
5423
+ }
5424
+
5425
+ if (tokenPayload?.error === 'authorization_pending') {
5426
+ if (!opts.json) process.stdout.write('[socialseal] Waiting for browser approval…\n');
5427
+ continue;
5428
+ }
5429
+ if (tokenPayload?.error === 'slow_down') {
5430
+ intervalMs = Math.min(intervalMs + 5000, 60000);
5431
+ continue;
5432
+ }
5433
+
5434
+ throw await buildHttpError(new Response(JSON.stringify(tokenPayload), {
5435
+ status: tokenRes.status,
5436
+ statusText: tokenRes.statusText,
5437
+ headers: { 'Content-Type': 'application/json' },
5438
+ }), { label: 'Device token poll' });
5439
+ }
5440
+
5441
+ throw new CliError('Timed out waiting for browser approval.', {
5442
+ code: 'DEVICE_LOGIN_TIMEOUT',
5443
+ exitCode: EXIT_CODES.AUTH,
5444
+ hint: 'Run `socialseal login` again when you are ready to approve in the browser.',
5445
+ });
5446
+ }
5447
+
5448
+ function handleLogout(opts) {
5449
+ const config = loadConfig();
5450
+ const hadApiKey = Boolean(resolveApiKey({}, config));
5451
+ const nextConfig = { ...config };
5452
+ delete nextConfig.apiKey;
5453
+ saveConfig(nextConfig);
5454
+
5455
+ const payload = {
5456
+ success: true,
5457
+ removedLocalKey: hadApiKey,
5458
+ configPath: getConfigPath(),
5459
+ };
5460
+ if (opts.json) {
5461
+ process.stdout.write(JSON.stringify(payload, null, opts.pretty ? 2 : 0) + '\n');
5462
+ return;
5463
+ }
5464
+ process.stdout.write('[socialseal] Logged out locally. Any server-side key remains revocable from SocialSeal settings.\n');
5465
+ }
5466
+
5467
+ async function handleWhoami(opts) {
5468
+ const config = loadConfig();
5469
+ const apiKey = requireApiKey(opts, config);
5470
+ const apiBase = resolveApiBase(opts, config);
5471
+ const { resolvedApiBase } = resolveApiTarget({ apiBase, legacyUrl: null });
5472
+ const timeoutMs = resolveTimeoutMs(opts, config);
5473
+ const directory = await fetchWorkspaceDirectory({
5474
+ apiBase: resolvedApiBase,
5475
+ apiKey,
5476
+ timeoutMs,
5477
+ });
5478
+ const selection = resolveWorkspaceSelection({}, config);
5479
+ const workspaces = Array.isArray(directory.workspaces) ? directory.workspaces : [];
5480
+ const workspace = selection.workspaceId
5481
+ ? workspaces.find((entry) => entry.id === selection.workspaceId) || null
5482
+ : null;
5483
+ const payload = {
5484
+ authenticated: true,
5485
+ apiBase: resolvedApiBase,
5486
+ key: maskApiKey(apiKey),
5487
+ keySuffix: apiKey.slice(-6),
5488
+ effectiveWorkspaceId: selection.workspaceId,
5489
+ effectiveWorkspaceSource: selection.source,
5490
+ workspace,
5491
+ workspaceCount: workspaces.length,
5492
+ };
5493
+
5494
+ if (opts.json) {
5495
+ process.stdout.write(JSON.stringify(payload, null, opts.pretty ? 2 : 0) + '\n');
5496
+ return;
5497
+ }
5498
+
5499
+ process.stdout.write(`[socialseal] Authenticated with key ${maskApiKey(apiKey)}\n`);
5500
+ if (workspace) {
5501
+ process.stdout.write(`[socialseal] Workspace: ${workspace.name} (${workspace.id})\n`);
5502
+ } else if (directory.defaultWorkspaceId) {
5503
+ process.stdout.write(`[socialseal] Suggested workspace: ${directory.defaultWorkspaceId}\n`);
5504
+ }
5505
+ }
5506
+
5507
+ function handleBilling(opts) {
5508
+ const config = loadConfig();
5509
+ const webBase = resolveWebBase(opts, config);
5510
+ const billingUrl = `${webBase.replace(/\/$/, '')}/settings/billing`;
5511
+ const payload = {
5512
+ billingUrl,
5513
+ note: 'SocialSeal starts on the free tier. Use billing only when credits or quotas are exhausted.',
5514
+ };
5515
+
5516
+ if (opts.json) {
5517
+ process.stdout.write(JSON.stringify(payload, null, opts.pretty ? 2 : 0) + '\n');
5518
+ return;
5519
+ }
5520
+
5521
+ process.stdout.write(`[socialseal] Billing and credits: ${billingUrl}\n`);
5522
+ process.stdout.write('[socialseal] SocialSeal starts on the free tier. Add billing only when you need more capacity.\n');
5523
+ }
5524
+
5232
5525
  async function handleWorkspaceList(opts) {
5233
5526
  const config = loadConfig();
5234
5527
  const apiKey = requireApiKey(opts, config);
@@ -5388,7 +5681,47 @@ if (typeof program.showHelpAfterError === 'function') {
5388
5681
  if (typeof program.showSuggestionAfterError === 'function') {
5389
5682
  program.showSuggestionAfterError(true);
5390
5683
  }
5391
- program.addHelpText('after', `\nExamples:\n socialseal workspace list\n socialseal workspace use <workspace-id>\n socialseal agent run --message "ping"\n socialseal tools list\n socialseal tools schema --function search-journey-run\n socialseal tools call --function <tool> --body @payload.json\n socialseal tools status 6809 --kind google_ai_run\n socialseal tools status <run-uuid> --kind journey_run --workspace-id <uuid>\n socialseal video queue-analysis --video-id 734829384 --workspace-id <uuid>\n socialseal video extract --video-id 734829384 --wait --out-dir ./video-assets\n socialseal data export-options\n socialseal data export-tracking --group-id 123 --time-period 30d\n socialseal data export-search-results --group-ids 123,124 --workspace-id <uuid> --out ranked.csv\n socialseal data export-group-evidence --group-id 123 --workspace-id <uuid> --out evidence.csv\n`);
5684
+ program.addHelpText('after', `\nExamples:\n socialseal login\n socialseal whoami\n socialseal workspace list\n socialseal workspace use <workspace-id>\n socialseal agent run --message "ping"\n socialseal tools list\n socialseal tools schema --function search-journey-run\n socialseal tools call --function <tool> --body @payload.json\n socialseal tools status 6809 --kind google_ai_run\n socialseal tools status <run-uuid> --kind journey_run --workspace-id <uuid>\n socialseal video queue-analysis --video-id 734829384 --workspace-id <uuid>\n socialseal video extract --video-id 734829384 --wait --out-dir ./video-assets\n socialseal data export-options\n socialseal data export-tracking --group-id 123 --time-period 30d\n socialseal data export-search-results --group-ids 123,124 --workspace-id <uuid> --out ranked.csv\n socialseal data export-group-evidence --group-id 123 --workspace-id <uuid> --out evidence.csv\n`);
5685
+
5686
+ program
5687
+ .command('login')
5688
+ .description('Start browser-based device login and store a local CLI key')
5689
+ .option('--api-base <url>', 'API base URL (default https://api.socialseal.co)')
5690
+ .option('--no-open', 'Print the approval URL without opening a browser')
5691
+ .option('--json', 'Emit machine-readable output')
5692
+ .option('--pretty', 'Pretty-print JSON')
5693
+ .option('--timeout <ms>', 'Overall login timeout in milliseconds')
5694
+ .option('--poll-interval <ms>', 'Polling interval in milliseconds')
5695
+ .option('--verbose', 'Show error details')
5696
+ .action((opts) => runCommand(handleLogin, opts));
5697
+
5698
+ program
5699
+ .command('logout')
5700
+ .description('Remove the locally stored SocialSeal CLI key')
5701
+ .option('--json', 'Emit machine-readable output')
5702
+ .option('--pretty', 'Pretty-print JSON')
5703
+ .option('--verbose', 'Show error details')
5704
+ .action((opts) => runCommand(handleLogout, opts));
5705
+
5706
+ program
5707
+ .command('whoami')
5708
+ .description('Show the current SocialSeal CLI authentication and workspace')
5709
+ .option('--api-base <url>', 'API base URL (default https://api.socialseal.co)')
5710
+ .option('--api-key <key>', 'CLI API key')
5711
+ .option('--json', 'Emit machine-readable output')
5712
+ .option('--pretty', 'Pretty-print JSON')
5713
+ .option('--timeout <ms>', 'Request timeout in milliseconds')
5714
+ .option('--verbose', 'Show error details')
5715
+ .action((opts) => runCommand(handleWhoami, opts));
5716
+
5717
+ program
5718
+ .command('billing')
5719
+ .description('Show where to manage SocialSeal billing and credits')
5720
+ .option('--web-base <url>', 'Web app base URL')
5721
+ .option('--json', 'Emit machine-readable output')
5722
+ .option('--pretty', 'Pretty-print JSON')
5723
+ .option('--verbose', 'Show error details')
5724
+ .action((opts) => runCommand(handleBilling, opts));
5392
5725
 
5393
5726
  program
5394
5727
  .command('agent')