@helix3/helix-cli 0.1.13-helix3.81 → 0.1.13-helix3.82

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/dist/index.js CHANGED
@@ -788,6 +788,255 @@ achievement
788
788
  fail(err instanceof lib_1.ApiError ? err.render() : `✖ ${err instanceof Error ? err.message : String(err)}`);
789
789
  }
790
790
  });
791
+ // --- helix product ---------------------------------------------------------
792
+ // The registry `Helix.purchase` sells from: a world can only sell what its
793
+ // creator registered here, and registering was API/website-only until these
794
+ // commands. ORDER, the INVERSE of achievements: publish the world FIRST — the
795
+ // creator registry lists Published worlds only, and nobody can buy inside a
796
+ // world they cannot enter. Worlds are named by SLUG, products by their key.
797
+ const product = program
798
+ .command('product')
799
+ .description("Register and manage a world's in-world products (what Helix.purchase sells). Publish the world FIRST — unlike achievements, a product only counts once the world is Published");
800
+ function renderProduct(p, indent = ' ') {
801
+ // An authored-grants product's stored type is server-DERIVED — the effect list is the truth,
802
+ // so the "your world applies it" hint only fits legacy consumables.
803
+ const lines = [
804
+ `${indent}price: ${p.priceLix} LIX${p.isFree ? ' — free "Claim"' : ''}`,
805
+ `${indent}type: ${p.type}${p.type === 'consumable' && !p.grants ? ' — your world applies the effect on a Granted result' : ''}`,
806
+ ];
807
+ // The key is how world code names it; without one the uuid is the only handle.
808
+ lines.push(`${indent}buy as: ${p.key ? `pkey:${p.key}` : `${p.id} (no key — register with --key next time)`}`);
809
+ if (p.grants) {
810
+ const effects = p.grants.map((g) => g.kind === 'item'
811
+ ? `item ${g.itemId}${(g.quantity ?? 1) > 1 ? ` ×${g.quantity}` : ''}`
812
+ : g.kind === 'pass'
813
+ ? `pass ${g.passKey ?? p.key ?? '(product key)'}${g.durationSeconds ? ` for ${g.durationSeconds}s` : ''}`
814
+ : `currency ${g.code} +${g.amount}`);
815
+ lines.push(`${indent}grants: ${effects.length ? effects.join(', ') : 'nothing — a tip jar'}`);
816
+ }
817
+ else if (p.itemId) {
818
+ lines.push(`${indent}grants: item ${p.itemId}`);
819
+ }
820
+ if (p.maxPerUser !== null)
821
+ lines.push(`${indent}max/user: ${p.maxPerUser}`);
822
+ if (!p.active)
823
+ lines.push(`${indent}active: no — off sale, existing owners keep theirs`);
824
+ lines.push(`${indent}id: ${p.id}`);
825
+ return lines;
826
+ }
827
+ // The effect list arrives as one JSON string. Only "is it an array" is checked
828
+ // here — every shape rule belongs to the vocabulary, which reports them together.
829
+ function parseGrantsFlag(value) {
830
+ let parsed;
831
+ try {
832
+ parsed = JSON.parse(value);
833
+ }
834
+ catch (err) {
835
+ const detail = err instanceof Error ? err.message : String(err);
836
+ fail(`✖ --grants must be a JSON array of effects — that is not valid JSON (${detail})`);
837
+ }
838
+ if (!Array.isArray(parsed)) {
839
+ fail('✖ --grants must be a JSON array of effects, e.g. \'[{"kind":"currency","code":"coin","amount":500}]\' (several = a bundle, [] = a tip jar)');
840
+ }
841
+ return parsed;
842
+ }
843
+ product
844
+ .command('register')
845
+ .description('Register a purchasable product on one of your PUBLISHED worlds — one round trip, live as soon as it returns')
846
+ .argument('<world-slug>', 'the world that sells it')
847
+ .requiredOption('--title <title>', 'name the purchase popup shows the player (max 120 chars)')
848
+ .requiredOption('--price <lix>', 'price in LIX; 0 registers a free "Claim" (a confirmation, no spend)')
849
+ .option('--type <type>', `what the purchase does: ${lib_1.WORLD_PRODUCT_TYPES.join(' | ')} — required unless you author --grants`)
850
+ .option('--grants <json>', `JSON array of what the purchase hands over, instead of --type/--item-id: item | pass | currency, max ${lib_1.MAX_GRANT_EFFECTS} ` +
851
+ "(e.g. '[{\"kind\":\"currency\",\"code\":\"coin\",\"amount\":500}]'; [] = a tip jar). Fixed at registration")
852
+ .option('--key <key>', 'stable slug world code buys as `pkey:<key>` (max 64 chars, lowercase a-z0-9_-); it cannot be renamed later')
853
+ .option('--description <text>', 'what the player is buying')
854
+ .option('--item-id <uuid>', 'the universal item handed over — REQUIRED for item_grant and non_consumable, refused for consumable')
855
+ .option('--max-per-user <n>', 'cap on how many one player may own (whole number >= 1)')
856
+ .option('--dry-run', 'validate everything locally and print what WOULD be sent; no network, works logged out')
857
+ .option('--json', 'machine-readable output')
858
+ .action(async (worldSlug, opts) => {
859
+ const registration = {
860
+ worldSlug,
861
+ key: opts.key,
862
+ title: opts.title,
863
+ description: opts.description,
864
+ priceLix: Number(opts.price),
865
+ type: opts.type,
866
+ itemId: opts.itemId,
867
+ grants: opts.grants !== undefined ? parseGrantsFlag(opts.grants) : undefined,
868
+ maxPerUser: opts.maxPerUser !== undefined ? parseWholeNumber(opts.maxPerUser, '--max-per-user') : undefined,
869
+ };
870
+ // Stops before ANY network call, so it works logged out — the point is to
871
+ // check the vocabulary and the type/item coherence, not the account.
872
+ if (opts.dryRun) {
873
+ try {
874
+ const plan = (0, lib_1.planProductRegistration)(registration);
875
+ if (opts.json) {
876
+ console.log(JSON.stringify({ dryRun: true, worldSlug, fields: plan.fields, warnings: plan.warnings }, null, 2));
877
+ return;
878
+ }
879
+ console.log('✔ Dry run — nothing sent.');
880
+ console.log(` POST /api/v1/iwp/products (worldId of "${worldSlug}")`);
881
+ console.log(` body: ${JSON.stringify(plan.fields, null, 2).replace(/\n/g, '\n ')}`);
882
+ for (const warning of plan.warnings)
883
+ console.log(` • ${warning}`);
884
+ if (!(0, lib_1.authStatus)().loggedIn)
885
+ console.log(' • not logged in — run `helix login` before the real registration');
886
+ }
887
+ catch (err) {
888
+ fail(`✖ ${err instanceof Error ? err.message : String(err)}`);
889
+ }
890
+ return;
891
+ }
892
+ const creds = requireAuth();
893
+ const mismatch = (0, config_1.credsEnvMismatchWarning)(creds.apiUrl);
894
+ if (mismatch)
895
+ fail(`✖ Refusing to register:\n${mismatch}`);
896
+ try {
897
+ const { product: created, warnings } = await (0, lib_1.registerProduct)(registration, creds, {
898
+ onProgress: (msg) => !opts.json && console.log(msg),
899
+ });
900
+ if (opts.json) {
901
+ console.log(JSON.stringify({ product: created, warnings }, null, 2));
902
+ return;
903
+ }
904
+ console.log(`✔ Registered "${created.title}" on ${worldSlug}`);
905
+ for (const line of renderProduct(created))
906
+ console.log(line);
907
+ for (const warning of warnings)
908
+ console.log(` ⚠ ${warning}`);
909
+ }
910
+ catch (err) {
911
+ fail(err instanceof lib_1.ApiError ? err.render() : `✖ ${err instanceof Error ? err.message : String(err)}`);
912
+ }
913
+ });
914
+ product
915
+ .command('list')
916
+ .description("List a world's registered products, or — with no slug — every PUBLISHED world of yours and what it sells")
917
+ .argument('[world-slug]', 'the world that sells them; omit for all your published worlds')
918
+ .option('--json', 'machine-readable output')
919
+ .action(async (worldSlug, opts) => {
920
+ const creds = requireAuth();
921
+ try {
922
+ if (!worldSlug) {
923
+ const worlds = await (0, lib_1.listProductWorlds)(creds);
924
+ if (opts.json) {
925
+ console.log(JSON.stringify(worlds, null, 2));
926
+ return;
927
+ }
928
+ if (worlds.length === 0) {
929
+ console.log('You have no published worlds — `helix publish` one first; a product on a draft world is unbuyable.');
930
+ return;
931
+ }
932
+ for (const world of worlds) {
933
+ console.log(`\n${world.slug} — "${world.title}" (${world.products.length} product${world.products.length === 1 ? '' : 's'})`);
934
+ for (const row of world.products) {
935
+ console.log(` ${row.key ?? row.id} — "${row.title}" · ${row.priceLix} LIX · ${row.type}${row.active ? '' : ' [INACTIVE]'}`);
936
+ }
937
+ }
938
+ return;
939
+ }
940
+ const rows = await (0, lib_1.listProducts)(worldSlug, creds);
941
+ if (opts.json) {
942
+ console.log(JSON.stringify(rows, null, 2));
943
+ return;
944
+ }
945
+ if (rows.length === 0) {
946
+ console.log(`No products registered on "${worldSlug}" yet — register one with: helix product register ${worldSlug} --title … --type … --price …`);
947
+ return;
948
+ }
949
+ console.log(`${rows.length} product${rows.length === 1 ? '' : 's'} on ${worldSlug}:`);
950
+ for (const row of rows) {
951
+ console.log(`\n ${row.key ?? row.id} — "${row.title}"${row.active ? '' : ' [INACTIVE]'}`);
952
+ console.log(` ${row.priceLix} LIX · ${row.type}${row.itemId ? ` · grants ${row.itemId}` : ''}${row.maxPerUser !== null ? ` · max ${row.maxPerUser}/user` : ''}`);
953
+ }
954
+ }
955
+ catch (err) {
956
+ fail(err instanceof lib_1.ApiError ? err.render() : `✖ ${err instanceof Error ? err.message : String(err)}`);
957
+ }
958
+ });
959
+ product
960
+ .command('update')
961
+ .description('Patch a registered product. The key and the type are fixed at registration — world code buys the key, and changing what a purchase hands over would re-label every copy already sold')
962
+ .argument('<world-slug>', 'the world that sells it')
963
+ .argument('<product>', 'the product key (or its uuid, if you registered it without a key)')
964
+ .option('--title <title>', 'new title')
965
+ .option('--description <text>', 'new description')
966
+ .option('--price <lix>', 'new price in LIX; 0 makes it a free "Claim"')
967
+ .option('--max-per-user <n>', 'new per-player ownership cap (whole number >= 1)')
968
+ .option('--active <true|false>', 'false takes it off sale — existing owners keep theirs, nobody new can buy it')
969
+ .option('--dry-run', 'validate the patch locally and print what WOULD be sent; no network, works logged out')
970
+ .option('--json', 'machine-readable output')
971
+ .action(async (worldSlug, ref, opts) => {
972
+ const patch = {
973
+ title: opts.title,
974
+ description: opts.description,
975
+ priceLix: opts.price !== undefined ? Number(opts.price) : undefined,
976
+ maxPerUser: opts.maxPerUser !== undefined ? parseWholeNumber(opts.maxPerUser, '--max-per-user') : undefined,
977
+ active: opts.active !== undefined ? parseBooleanFlag(opts.active, '--active') : undefined,
978
+ };
979
+ if (opts.dryRun) {
980
+ try {
981
+ const body = (0, lib_1.buildProductUpdate)(patch);
982
+ if (opts.json) {
983
+ console.log(JSON.stringify({ dryRun: true, worldSlug, product: ref, patch: body }, null, 2));
984
+ return;
985
+ }
986
+ console.log('✔ Dry run — nothing sent.');
987
+ console.log(` PATCH /api/v1/iwp/products/<id of "${ref}" on ${worldSlug}>`);
988
+ console.log(` body: ${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}`);
989
+ }
990
+ catch (err) {
991
+ fail(`✖ ${err instanceof Error ? err.message : String(err)}`);
992
+ }
993
+ return;
994
+ }
995
+ const creds = requireAuth();
996
+ const mismatch = (0, config_1.credsEnvMismatchWarning)(creds.apiUrl);
997
+ if (mismatch)
998
+ fail(`✖ Refusing to update:\n${mismatch}`);
999
+ try {
1000
+ const updated = await (0, lib_1.updateProduct)(worldSlug, ref, patch, creds);
1001
+ if (opts.json) {
1002
+ console.log(JSON.stringify(updated, null, 2));
1003
+ return;
1004
+ }
1005
+ console.log(`✔ Updated "${updated.title}" on ${worldSlug}`);
1006
+ for (const line of renderProduct(updated))
1007
+ console.log(line);
1008
+ }
1009
+ catch (err) {
1010
+ fail(err instanceof lib_1.ApiError ? err.render() : `✖ ${err instanceof Error ? err.message : String(err)}`);
1011
+ }
1012
+ });
1013
+ product
1014
+ .command('set-active')
1015
+ .description('Put a product on sale or take it off. The row stays either way, so past purchases and their grants keep resolving')
1016
+ .argument('<world-slug>', 'the world that sells it')
1017
+ .argument('<product>', 'the product key (or its uuid)')
1018
+ .argument('<true|false>', 'true puts it on sale, false takes it off')
1019
+ .option('--json', 'machine-readable output')
1020
+ .action(async (worldSlug, ref, value, opts) => {
1021
+ const active = parseBooleanFlag(value, '<true|false>');
1022
+ const creds = requireAuth();
1023
+ const mismatch = (0, config_1.credsEnvMismatchWarning)(creds.apiUrl);
1024
+ if (mismatch)
1025
+ fail(`✖ Refusing to change what a world sells:\n${mismatch}`);
1026
+ try {
1027
+ const updated = await (0, lib_1.setProductActive)(worldSlug, ref, active, creds);
1028
+ if (opts.json) {
1029
+ console.log(JSON.stringify(updated, null, 2));
1030
+ return;
1031
+ }
1032
+ console.log(`✔ "${updated.title}" is ${updated.active ? 'ON SALE' : 'off sale'} on ${worldSlug}`);
1033
+ for (const line of renderProduct(updated))
1034
+ console.log(line);
1035
+ }
1036
+ catch (err) {
1037
+ fail(err instanceof lib_1.ApiError ? err.render() : `✖ ${err instanceof Error ? err.message : String(err)}`);
1038
+ }
1039
+ });
791
1040
  // --- helix assets ----------------------------------------------------------
792
1041
  // Operational asset work lives here. MCP surfaces may point agents at these
793
1042
  // commands, but they must not reimplement Vault, Dreamer, download, or receipt