@helix3/helix-cli 0.1.13-helix3.79 → 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/README.md CHANGED
@@ -41,7 +41,7 @@ The token is stored in `~/.helix/credentials.json` (mode `600`). It's sent as
41
41
  | `helix init <dir>` | Scaffold a minimal Three.js world (`helix.json` + `index.html` + `main.js`). |
42
42
  | `helix install [--update]` | Resolve a world's `systems`/`abilities` pins (a v0.2 **or v0.3** manifest) → materialize the modules, the `three` import map, and `helix.runtime.ts`. |
43
43
  | `helix validate [dir]` | Validate a bundle locally — the exact rules the server enforces. |
44
- | `helix publish [dir] [--thumbnail <file>]` | Validate → resolve/create the world → upload files → finalize → set the cover image → print the play URL. |
44
+ | `helix publish [dir] [--thumbnail <file>] [--upload-source] [--source-dir <path>]` | Validate → resolve/create the world → upload files → finalize → set the cover image → print the play URL. `--upload-source` additionally sends the project's source through a separate private channel so the world can be edited on the website later — **off by default**. |
45
45
  | `helix list` | List your worlds. |
46
46
  | `helix item list-slots [--json]` | Print the Character-Creator vocabulary a wearable declares: the cosmetic slot tags, and the genders. |
47
47
  | `helix item publish <mesh.glb> --title <t> [--kind <k>] [--slot <tag>] [--gender <g>] [--price-lix <n> \| --personal] [--thumbnail <f>] [--dry-run]` | Publish a universal item (wearable / avatar / home item) from one `.glb`; the server verifies the mesh inline. |
@@ -71,6 +71,29 @@ The token is stored in `~/.helix/credentials.json` (mode `600`). It's sent as
71
71
  5. `POST /api/v1/instant-worlds/:id/builds/:buildId/finalize` → byte-exact verify, activate, publish.
72
72
  6. `GET /api/v1/instant-worlds/:slug` → the play URL.
73
73
 
74
+ ### Opt-in source upload
75
+
76
+ A published world ships only its **built bundle**, so there is nothing on the server to edit later.
77
+ `--upload-source` adds a second, separate upload that fixes that:
78
+
79
+ 7. `POST /api/v1/instant-worlds/:id/source` → a presigned PUT into a **private** bucket (this call
80
+ also adopts the world into a project, which is the key its future workspace hangs from).
81
+ 8. `helix-source.tar.gz` PUT with `cache-control: no-store`.
82
+ 9. `POST /api/v1/instant-worlds/:id/source/finalize` → the server verifies the stored byte count
83
+ equals the declared one, then records the version.
84
+
85
+ - **Off by default.** Source is never sent unless you pass the flag. Not a prompt — an interactive
86
+ confirm would break CI and agent-driven publishes.
87
+ - **What goes in**: the project directory, honouring `.gitignore`, always excluding `node_modules`,
88
+ `.git`, `dist` and `.vite` — the same exclude set the website's own workspace checkpoints use.
89
+ - **Which directory**: `[dir]` is the BUILT bundle, so the source defaults to its parent. Override
90
+ with `--source-dir <path>`.
91
+ - **Never part of the build.** The bundle's content-type allowlist rejects archives, raw `.ts` is
92
+ excluded from a bundle by design, the bundle size budget is the playable budget, and every build
93
+ file lands on a public CDN path with a year-long immutable cache. Source uses its own private
94
+ channel for exactly that reason.
95
+ - **Limit**: 128 MiB compressed, checked locally before anything is uploaded.
96
+
74
97
  The programmatic surface (`publishWorld`, `checkBundle`, `whoAmI`, …) is exported for other CLI
75
98
  modules. Agent integrations should instruct or delegate to the `helix` commands above rather than
76
99
  reimplementing operational behavior in a transport package.
package/dist/index.js CHANGED
@@ -308,6 +308,11 @@ program
308
308
  .option('--skip-subpath-check', 'skip the pre-publish sub-path load check (not recommended: it is the only gate that serves the bundle the way production does)')
309
309
  .option('--description <text>', `catalog description, max ${lib_1.WORLD_DESCRIPTION_MAX} chars (pass "" to clear). helix.json cannot carry this — the manifest schema forbids unknown fields`)
310
310
  .option('--description-file <path>', 'read the catalog description from a file')
311
+ .option('--upload-source', "Also upload this project's source so you can edit this world from the HELIX website later. " +
312
+ 'Off by default — your source is not sent unless you pass this.')
313
+ .option('--source-dir <path>', "project directory to archive for --upload-source (default: the bundle directory's parent, " +
314
+ 'since [directory] points at the BUILT bundle). node_modules, .git, dist and .vite are always excluded, ' +
315
+ 'as is anything your .gitignore excludes')
311
316
  .action(async (directory, opts) => {
312
317
  // Pure input checks first — bad flags should fail before auth, the network, or any upload.
313
318
  if (opts.description !== undefined && opts.descriptionFile) {
@@ -325,6 +330,9 @@ program
325
330
  if (description !== undefined && description.length > lib_1.WORLD_DESCRIPTION_MAX) {
326
331
  fail(`✖ description is ${description.length} chars; the limit is ${lib_1.WORLD_DESCRIPTION_MAX}. Nothing was published.`);
327
332
  }
333
+ if (opts.sourceDir && !opts.uploadSource) {
334
+ fail('✖ --source-dir only means something with --upload-source. Source is never uploaded unless you ask for it.');
335
+ }
328
336
  const creds = requireAuth();
329
337
  const mismatch = (0, config_1.credsEnvMismatchWarning)(creds.apiUrl);
330
338
  if (mismatch)
@@ -353,6 +361,8 @@ program
353
361
  const result = await (0, lib_1.publishWorld)((0, node_path_1.resolve)(directory), creds, (msg) => console.log(msg), {
354
362
  thumbnail: opts.thumbnail ? (0, node_path_1.resolve)(opts.thumbnail) : undefined,
355
363
  description,
364
+ uploadSource: opts.uploadSource,
365
+ sourceDir: opts.sourceDir ? (0, node_path_1.resolve)(opts.sourceDir) : undefined,
356
366
  });
357
367
  console.log(`✔ Published "${result.title}" build ${result.buildNumber}`);
358
368
  if (result.playUrl)
@@ -363,6 +373,10 @@ program
363
373
  }
364
374
  if (result.thumbnailWarning)
365
375
  console.warn(` • ${result.thumbnailWarning}`);
376
+ // Spec §9.3: one line about source, every publish, as a FACT — not a
377
+ // warning and never repeated. It is what tells a creator that editing on
378
+ // the website is a thing, and that it did not happen behind their back.
379
+ console.log(` ${(0, lib_1.sourceOutcomeLine)(result.source, creds.apiUrl)}`);
366
380
  }
367
381
  catch (err) {
368
382
  fail(err instanceof lib_1.ApiError
@@ -774,6 +788,255 @@ achievement
774
788
  fail(err instanceof lib_1.ApiError ? err.render() : `✖ ${err instanceof Error ? err.message : String(err)}`);
775
789
  }
776
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
+ });
777
1040
  // --- helix assets ----------------------------------------------------------
778
1041
  // Operational asset work lives here. MCP surfaces may point agents at these
779
1042
  // commands, but they must not reimplement Vault, Dreamer, download, or receipt