cubelife 0.2.1 → 0.3.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.
@@ -53,16 +53,19 @@ export function registerBillingCommands(program) {
53
53
  tier,
54
54
  price: formatPrice(tier),
55
55
  apiCalls: { today: todayUsage, limit: limits.calls },
56
- sparks: billingUser.sparks,
57
- dormancyState: billingUser.dormancyState,
56
+ burstSparks: billingUser.burstSparks ?? 0,
57
+ gems: billingUser.gems ?? 0,
58
+ sleeping: billingUser.sleeping ?? false,
58
59
  }));
59
60
  return;
60
61
  }
62
+ const status = billingUser.sleeping ? brand.warning('sleeping') : 'active';
61
63
  const lines = [
62
64
  `${label('Plan')}${limits.label} (${formatPrice(tier)})`,
63
65
  `${label('API Calls')}${usageWarning(todayUsage, limits.calls)}`,
64
- `${label('Sparks')}${billingUser.sparks.toLocaleString()}`,
65
- `${label('Status')}${billingUser.dormancyState ?? 'active'}`,
66
+ `${label('Burst Sparks')}${(billingUser.burstSparks ?? 0).toLocaleString()}`,
67
+ `${label('Gems')}${(billingUser.gems ?? 0).toLocaleString()}`,
68
+ `${label('Status')}${status}`,
66
69
  ];
67
70
  console.log(panel(lines, { title: 'Billing Overview', width: 56 }));
68
71
  p.log.message(brand.muted(`Run ${brand.accent('cubelife billing usage')} for 7-day history`));
@@ -351,7 +354,371 @@ export function registerBillingCommands(program) {
351
354
  handleCommandError({ error: err, json });
352
355
  }
353
356
  });
357
+ // --- Spark Packs ---
358
+ const sparks = billing.command('sparks').description('Buy Spark packs');
359
+ sparks
360
+ .command('list', { isDefault: true })
361
+ .description('List available Spark packs')
362
+ .action(async function () {
363
+ const { json } = rootOpts(this);
364
+ if (json) {
365
+ console.log(JSON.stringify({ packs: SPARK_PACKS }));
366
+ return;
367
+ }
368
+ console.log(table([
369
+ { label: 'Pack', key: 'label', width: 10 },
370
+ { label: 'Sparks', key: 'sparks', width: 10 },
371
+ { label: 'Price', key: 'price', width: 10 },
372
+ ], SPARK_PACKS.map((pk) => ({ label: pk.label, sparks: pk.sparks.toLocaleString(), price: formatCents(pk.priceZarCents) }))));
373
+ });
374
+ sparks
375
+ .command('buy <pack>')
376
+ .description('Buy a Spark pack (ember|flame|blaze|inferno)')
377
+ .action(async function (packId) {
378
+ const { json, yes } = rootOpts(this);
379
+ const pack = SPARK_PACKS.find((pk) => pk.id === packId);
380
+ if (!pack) {
381
+ if (json)
382
+ console.log(JSON.stringify({ error: 'invalid_pack', valid: SPARK_PACKS.map((pk) => pk.id) }));
383
+ else
384
+ p.log.error(`Invalid pack. Valid options: ${SPARK_PACKS.map((pk) => brand.accent(pk.id)).join(', ')}`);
385
+ process.exit(1);
386
+ }
387
+ try {
388
+ await doPurchase({
389
+ json, yes,
390
+ title: `Buy ${pack.label} Spark Pack`,
391
+ description: `${pack.sparks} Sparks for ${formatCents(pack.priceZarCents)}`,
392
+ initiate: (client, email) => client.initiateTopUp({ packId: pack.id, email, callbackUrl: `${LIFE_CALLBACK_URL}?cb=billing&type=spark_topup` }),
393
+ verify: (client, ref) => client.verifyTopUp(ref),
394
+ successMsg: `${pack.sparks} Sparks added to your account`,
395
+ });
396
+ }
397
+ catch (err) {
398
+ handleCommandError({ error: err, json });
399
+ }
400
+ });
401
+ // --- Gem Packs ---
402
+ const gems = billing.command('gems').description('Buy Gem packs');
403
+ gems
404
+ .command('list', { isDefault: true })
405
+ .description('List available Gem packs')
406
+ .action(async function () {
407
+ const { json } = rootOpts(this);
408
+ if (json) {
409
+ console.log(JSON.stringify({ packs: GEM_PACKS }));
410
+ return;
411
+ }
412
+ console.log(table([
413
+ { label: 'Pack', key: 'label', width: 10 },
414
+ { label: 'Gems', key: 'gems', width: 10 },
415
+ { label: 'Price', key: 'price', width: 10 },
416
+ ], GEM_PACKS.map((pk) => ({ label: pk.label, gems: pk.gems.toLocaleString(), price: formatCents(pk.priceZarCents) }))));
417
+ });
418
+ gems
419
+ .command('buy <pack>')
420
+ .description('Buy a Gem pack (shard|crystal|cluster|vein)')
421
+ .action(async function (packId) {
422
+ const { json, yes } = rootOpts(this);
423
+ const pack = GEM_PACKS.find((pk) => pk.id === packId);
424
+ if (!pack) {
425
+ if (json)
426
+ console.log(JSON.stringify({ error: 'invalid_pack', valid: GEM_PACKS.map((pk) => pk.id) }));
427
+ else
428
+ p.log.error(`Invalid pack. Valid options: ${GEM_PACKS.map((pk) => brand.accent(pk.id)).join(', ')}`);
429
+ process.exit(1);
430
+ }
431
+ try {
432
+ await doPurchase({
433
+ json, yes,
434
+ title: `Buy ${pack.label} Gem Pack`,
435
+ description: `${pack.gems} Gems for ${formatCents(pack.priceZarCents)}`,
436
+ initiate: (client, email) => client.initiateGemTopUp({ packId: pack.id, email, callbackUrl: `${LIFE_CALLBACK_URL}?cb=billing&type=gem_topup` }),
437
+ verify: (client, ref) => client.verifyGemTopUp(ref),
438
+ successMsg: `${pack.gems} Gems added to your account`,
439
+ });
440
+ }
441
+ catch (err) {
442
+ handleCommandError({ error: err, json });
443
+ }
444
+ });
445
+ // --- Vault ---
446
+ billing
447
+ .command('vault')
448
+ .description('Convert Burst Sparks to Gems (3:1 ratio)')
449
+ .option('--amount <n>', 'Number of sparks to convert')
450
+ .action(async function (opts) {
451
+ const { json, yes } = rootOpts(this);
452
+ try {
453
+ const session = await requireAuth();
454
+ const client = new AdminClient(session.token);
455
+ const billingUser = await client.getBillingUser();
456
+ const tier = resolveTier(billingUser.tier);
457
+ if (tier === 'free') {
458
+ if (json)
459
+ console.log(JSON.stringify({ error: 'free_tier' }));
460
+ else
461
+ p.log.error('Vaulting is available on Standard and Pro plans.');
462
+ process.exit(1);
463
+ }
464
+ const burstSparks = billingUser.burstSparks ?? 0;
465
+ if (burstSparks < 3) {
466
+ if (json)
467
+ console.log(JSON.stringify({ error: 'insufficient_sparks', burstSparks }));
468
+ else
469
+ p.log.error(`Not enough Burst Sparks to vault (need at least 3, have ${burstSparks}).`);
470
+ process.exit(1);
471
+ }
472
+ const amount = opts.amount ? parseInt(opts.amount, 10) : undefined;
473
+ if (amount !== undefined && (isNaN(amount) || amount < 3 || amount > burstSparks)) {
474
+ if (json)
475
+ console.log(JSON.stringify({ error: 'invalid_amount' }));
476
+ else
477
+ p.log.error(`Amount must be between 3 and ${burstSparks}.`);
478
+ process.exit(1);
479
+ }
480
+ const raw = amount ?? burstSparks;
481
+ const sparksToConvert = Math.floor(raw / 3) * 3;
482
+ const gemsToGain = sparksToConvert / 3;
483
+ if (sparksToConvert === 0) {
484
+ if (json)
485
+ console.log(JSON.stringify({ error: 'insufficient_sparks', burstSparks }));
486
+ else
487
+ p.log.error(`Not enough Burst Sparks to vault (need at least 3, have ${burstSparks}).`);
488
+ process.exit(1);
489
+ }
490
+ if (!json && !yes) {
491
+ const lines = [
492
+ `${label('Convert')}${sparksToConvert} Burst Sparks`,
493
+ `${label('Receive')}${gemsToGain} Gems`,
494
+ `${label('Remaining')}${burstSparks - sparksToConvert} Burst Sparks`,
495
+ ];
496
+ console.log(panel(lines, { title: 'Vault Conversion', width: 44 }));
497
+ const confirmed = await p.confirm({ message: 'Confirm vault conversion?' });
498
+ if (isCancel(confirmed) || !confirmed) {
499
+ p.cancel('Cancelled.');
500
+ return;
501
+ }
502
+ }
503
+ const spin = p.spinner();
504
+ if (!json)
505
+ spin.start('Converting sparks to gems');
506
+ const result = await client.vaultSparks(sparksToConvert);
507
+ if (!json) {
508
+ spin.stop('Conversion complete');
509
+ p.log.success(`${result.gemsAdded} Gems added. Balance: ${result.gemsBalance} Gems, ${result.burstSparksRemaining} Burst Sparks remaining.`);
510
+ }
511
+ else {
512
+ console.log(JSON.stringify(result));
513
+ }
514
+ }
515
+ catch (err) {
516
+ handleCommandError({ error: err, json });
517
+ }
518
+ });
519
+ // --- Cancel ---
520
+ billing
521
+ .command('cancel')
522
+ .description('Cancel your subscription')
523
+ .action(async function () {
524
+ const { json, yes } = rootOpts(this);
525
+ try {
526
+ const session = await requireAuth();
527
+ const client = new AdminClient(session.token);
528
+ const billingUser = await client.getBillingUser();
529
+ const tier = resolveTier(billingUser.tier);
530
+ if (tier === 'free') {
531
+ if (json)
532
+ console.log(JSON.stringify({ error: 'already_free' }));
533
+ else
534
+ p.log.warn('Already on the Free plan.');
535
+ process.exit(1);
536
+ }
537
+ if (!json && !yes) {
538
+ const lines = [
539
+ `${label('Current plan')}${TIER_LIMITS[tier].label} (${formatPrice(tier)})`,
540
+ `${label('After cancel')}Free`,
541
+ ];
542
+ console.log(panel(lines, { title: 'Cancel Subscription', width: 44 }));
543
+ const confirmed = await p.confirm({ message: 'Are you sure you want to cancel?' });
544
+ if (isCancel(confirmed) || !confirmed) {
545
+ p.cancel('Kept current plan.');
546
+ return;
547
+ }
548
+ }
549
+ const spin = p.spinner();
550
+ if (!json)
551
+ spin.start('Cancelling subscription');
552
+ await client.cancelSubscription();
553
+ if (!json) {
554
+ spin.stop('Subscription cancelled');
555
+ p.log.success('Your plan has been changed to Free.');
556
+ }
557
+ else {
558
+ console.log(JSON.stringify({ cancelled: true }));
559
+ }
560
+ }
561
+ catch (err) {
562
+ handleCommandError({ error: err, json });
563
+ }
564
+ });
565
+ // --- Downgrade Preview ---
566
+ billing
567
+ .command('downgrade-preview')
568
+ .description('Preview the impact of downgrading your plan')
569
+ .option('--target <tier>', 'Target tier (free or standard)', 'free')
570
+ .action(async function (opts) {
571
+ const { json } = rootOpts(this);
572
+ try {
573
+ const session = await requireAuth();
574
+ const client = new AdminClient(session.token);
575
+ const spin = p.spinner();
576
+ if (!json)
577
+ spin.start('Loading preview');
578
+ const preview = await client.getDowngradePreview(opts.target);
579
+ if (!json)
580
+ spin.stop('Preview loaded');
581
+ if (json) {
582
+ console.log(JSON.stringify(preview));
583
+ return;
584
+ }
585
+ const lines = [
586
+ `${label('Current')}${preview.currentTier}`,
587
+ `${label('Target')}${preview.targetTier}`,
588
+ ];
589
+ if (preview.impacts?.length) {
590
+ lines.push('');
591
+ for (const impact of preview.impacts) {
592
+ const warn = impact.current > impact.limit;
593
+ const line = `${impact.label}: ${impact.current} → limit ${impact.limit}`;
594
+ lines.push(` ${warn ? brand.warning(line) : line}`);
595
+ }
596
+ }
597
+ if (preview.lostFeatures?.length) {
598
+ lines.push('');
599
+ lines.push(brand.warning(`Features lost: ${preview.lostFeatures.join(', ')}`));
600
+ }
601
+ console.log(panel(lines, { title: 'Downgrade Preview', width: 52 }));
602
+ p.log.message(brand.muted(`Run ${brand.accent('cubelife billing cancel')} to proceed.`));
603
+ }
604
+ catch (err) {
605
+ handleCommandError({ error: err, json });
606
+ }
607
+ });
608
+ // --- Starter Pack ---
609
+ billing
610
+ .command('starter-pack')
611
+ .description('Buy the Starter Pack (one-time, R40)')
612
+ .action(async function () {
613
+ const { json, yes } = rootOpts(this);
614
+ try {
615
+ const session = await requireAuth();
616
+ const client = new AdminClient(session.token);
617
+ const billingUser = await client.getBillingUser();
618
+ if (billingUser.starterPackClaimed) {
619
+ if (json)
620
+ console.log(JSON.stringify({ error: 'already_claimed' }));
621
+ else
622
+ p.log.warn('Starter Pack already claimed.');
623
+ process.exit(1);
624
+ }
625
+ await doPurchase({
626
+ json, yes, session,
627
+ title: 'Buy Starter Pack',
628
+ description: '30 Gems + exclusive item for R40.00',
629
+ initiate: (client2, email) => client2.initiateStarterPack({ email, callbackUrl: `${LIFE_CALLBACK_URL}?cb=billing&type=starter_pack` }),
630
+ verify: (client2, ref) => client2.verifyStarterPack(ref),
631
+ successMsg: 'Starter Pack claimed! 30 Gems and exclusive item added.',
632
+ });
633
+ }
634
+ catch (err) {
635
+ handleCommandError({ error: err, json });
636
+ }
637
+ });
638
+ }
639
+ async function doPurchase(opts) {
640
+ const { json, yes, title, description, initiate, verify, successMsg } = opts;
641
+ const session = opts.session ?? await requireAuth();
642
+ const client = new AdminClient(session.token);
643
+ if (!json && !yes) {
644
+ console.log(panel([description], { title, width: 44 }));
645
+ const confirmed = await p.confirm({ message: 'Proceed to checkout?' });
646
+ if (isCancel(confirmed) || !confirmed) {
647
+ p.cancel('Cancelled.');
648
+ return;
649
+ }
650
+ }
651
+ const spin = p.spinner();
652
+ if (!json)
653
+ spin.start('Initiating checkout');
654
+ const result = await initiate(client, session.email);
655
+ if (!json)
656
+ spin.stop('Checkout ready');
657
+ if (json) {
658
+ console.log(JSON.stringify({ authorisationUrl: result.authorisationUrl, reference: result.reference }));
659
+ }
660
+ if (!json) {
661
+ const opened = await openBrowser(result.authorisationUrl);
662
+ if (opened)
663
+ p.log.info('Opening PayStack checkout in your browser...');
664
+ else
665
+ p.log.info('Open this URL to complete checkout:');
666
+ p.log.message(brand.accent(result.authorisationUrl));
667
+ }
668
+ const pollSpin = p.spinner();
669
+ if (!json)
670
+ pollSpin.start('Waiting for payment...');
671
+ let pollResult;
672
+ try {
673
+ pollResult = await pollVerification(client, result.reference, {
674
+ verify: (c, ref) => verify(c, ref),
675
+ });
676
+ }
677
+ catch (pollErr) {
678
+ if (!json)
679
+ pollSpin.stop('Failed');
680
+ throw pollErr;
681
+ }
682
+ if (pollResult.status === 'success') {
683
+ if (!json) {
684
+ pollSpin.stop('Payment confirmed');
685
+ p.log.success(successMsg);
686
+ }
687
+ else
688
+ console.log(JSON.stringify({ verified: true }));
689
+ }
690
+ else if (pollResult.status === 'timeout') {
691
+ if (!json) {
692
+ pollSpin.stop('Timed out');
693
+ p.log.warn('Payment verification timed out. The payment may still complete.');
694
+ p.log.message(brand.muted(`Reference: ${result.reference}`));
695
+ }
696
+ else {
697
+ console.log(JSON.stringify({ error: 'timeout', reference: result.reference }));
698
+ }
699
+ }
700
+ else {
701
+ if (!json) {
702
+ pollSpin.stop('Payment not completed');
703
+ p.log.error(`Payment ${pollResult.status}.`);
704
+ }
705
+ else
706
+ console.log(JSON.stringify({ error: pollResult.status }));
707
+ }
354
708
  }
709
+ // --- Pack data ---
710
+ const SPARK_PACKS = [
711
+ { id: 'ember', sparks: 50, priceZarCents: 4_000, label: 'Ember' },
712
+ { id: 'flame', sparks: 150, priceZarCents: 10_000, label: 'Flame' },
713
+ { id: 'blaze', sparks: 500, priceZarCents: 30_000, label: 'Blaze' },
714
+ { id: 'inferno', sparks: 1_500, priceZarCents: 80_000, label: 'Inferno' },
715
+ ];
716
+ const GEM_PACKS = [
717
+ { id: 'shard', gems: 50, priceZarCents: 3_000, label: 'Shard' },
718
+ { id: 'crystal', gems: 200, priceZarCents: 10_000, label: 'Crystal' },
719
+ { id: 'cluster', gems: 600, priceZarCents: 25_000, label: 'Cluster' },
720
+ { id: 'vein', gems: 1_500, priceZarCents: 50_000, label: 'Vein' },
721
+ ];
355
722
  function featureRow(featureLabel, getValue, currentTier) {
356
723
  const lbl = brand.label((' ' + featureLabel).padEnd(14));
357
724
  const values = TIER_ORDER.map((t) => {
package/dist/lib/api.d.ts CHANGED
@@ -6,6 +6,9 @@ export type Tier = 'free' | 'standard' | 'pro';
6
6
  export interface BillingUser {
7
7
  tier: Tier;
8
8
  sparks: number;
9
+ burstSparks?: number;
10
+ gems?: number;
11
+ sleeping?: boolean;
9
12
  dormancyState: string;
10
13
  starterPackClaimed?: boolean;
11
14
  locale?: string;
@@ -19,14 +22,36 @@ export interface UsageDay {
19
22
  }
20
23
  export interface BillingHistoryItem {
21
24
  id: string;
22
- type: 'subscription' | 'spark_topup' | 'starter_pack' | 'cosmetic';
25
+ type: 'subscription' | 'subscription_ended' | 'spark_topup' | 'gem_topup' | 'starter_pack' | 'cosmetic';
23
26
  product: 'world' | 'life';
24
27
  amount: number;
25
28
  currency: string;
26
29
  sparks: number | null;
30
+ gems: number | null;
27
31
  status: 'success' | 'failed';
28
32
  paystackReference: string;
29
- createdAt: string;
33
+ createdAt: unknown;
34
+ }
35
+ export interface VaultResponse {
36
+ gemsAdded: number;
37
+ gemsBalance: number;
38
+ burstSparksRemaining: number;
39
+ }
40
+ export interface DowngradePreview {
41
+ currentTier: string;
42
+ targetTier: string;
43
+ impacts: Array<{
44
+ key: string;
45
+ label: string;
46
+ current: number;
47
+ limit: number;
48
+ }>;
49
+ lostFeatures: string[];
50
+ }
51
+ export interface TopUpRequest {
52
+ packId: string;
53
+ email: string;
54
+ callbackUrl: string;
30
55
  }
31
56
  export interface SubscribeRequest {
32
57
  plan: string;
@@ -138,6 +163,17 @@ export declare class AdminClient {
138
163
  getBillingHistory(limit?: number): Promise<BillingHistoryItem[]>;
139
164
  subscribe(opts: SubscribeRequest): Promise<SubscribeResponse>;
140
165
  verifyPayment(reference: string): Promise<VerifyResponse>;
166
+ initiateTopUp(opts: TopUpRequest): Promise<SubscribeResponse>;
167
+ verifyTopUp(reference: string): Promise<VerifyResponse>;
168
+ initiateGemTopUp(opts: TopUpRequest): Promise<SubscribeResponse>;
169
+ verifyGemTopUp(reference: string): Promise<VerifyResponse>;
170
+ initiateStarterPack(opts: Omit<TopUpRequest, 'packId'>): Promise<SubscribeResponse>;
171
+ verifyStarterPack(reference: string): Promise<VerifyResponse>;
172
+ vaultSparks(amount?: number): Promise<VaultResponse>;
173
+ cancelSubscription(): Promise<{
174
+ cancelled: boolean;
175
+ }>;
176
+ getDowngradePreview(targetTier: string): Promise<DowngradePreview>;
141
177
  listProjects(): Promise<ProjectListResponse>;
142
178
  createProject(name: string): Promise<ProjectResponse>;
143
179
  deleteProject(id: string): Promise<void>;
package/dist/lib/api.js CHANGED
@@ -75,6 +75,33 @@ export class AdminClient {
75
75
  async verifyPayment(reference) {
76
76
  return this.request('POST', '/v1/billing/verify', { reference });
77
77
  }
78
+ async initiateTopUp(opts) {
79
+ return this.request('POST', '/v1/billing/topup', opts);
80
+ }
81
+ async verifyTopUp(reference) {
82
+ return this.request('POST', '/v1/billing/topup/verify', { reference });
83
+ }
84
+ async initiateGemTopUp(opts) {
85
+ return this.request('POST', '/v1/billing/gem-topup', opts);
86
+ }
87
+ async verifyGemTopUp(reference) {
88
+ return this.request('POST', '/v1/billing/gem-topup/verify', { reference });
89
+ }
90
+ async initiateStarterPack(opts) {
91
+ return this.request('POST', '/v1/billing/starter-pack', opts);
92
+ }
93
+ async verifyStarterPack(reference) {
94
+ return this.request('POST', '/v1/billing/starter-pack/verify', { reference });
95
+ }
96
+ async vaultSparks(amount) {
97
+ return this.request('POST', '/v1/billing/vault', amount ? { amount } : {});
98
+ }
99
+ async cancelSubscription() {
100
+ return this.request('POST', '/v1/billing/cancel');
101
+ }
102
+ async getDowngradePreview(targetTier) {
103
+ return this.request('GET', `/v1/billing/downgrade-preview?targetTier=${encodeURIComponent(targetTier)}`);
104
+ }
78
105
  async listProjects() {
79
106
  return this.request('GET', '/v1/projects');
80
107
  }
@@ -7,5 +7,8 @@ export interface PollResult {
7
7
  export interface PollOptions {
8
8
  interval?: number;
9
9
  timeout?: number;
10
+ verify?: (client: AdminClient, reference: string) => Promise<{
11
+ verified: boolean;
12
+ }>;
10
13
  }
11
14
  export declare function pollVerification(client: AdminClient, reference: string, opts?: PollOptions): Promise<PollResult>;
package/dist/lib/poll.js CHANGED
@@ -5,9 +5,10 @@ export async function pollVerification(client, reference, opts = {}) {
5
5
  const interval = opts.interval ?? 5_000;
6
6
  const timeout = opts.timeout ?? 300_000;
7
7
  const deadline = Date.now() + timeout;
8
+ const verifyFn = opts.verify ?? ((c, ref) => c.verifyPayment(ref));
8
9
  while (Date.now() < deadline) {
9
10
  try {
10
- const data = await client.verifyPayment(reference);
11
+ const data = await verifyFn(client, reference);
11
12
  return { status: 'success', data };
12
13
  }
13
14
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cubelife",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for CubeLife — give your AI agent a living pixel-art companion",
5
5
  "type": "module",
6
6
  "bin": {