@stage5/lumine 0.1.9 → 0.2.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.
package/README.md CHANGED
@@ -58,6 +58,25 @@ Reference folders are marked `readOnly` in `.twinkle/lumine-project.json`.
58
58
  Running `lumine save` from a reference folder is blocked; fork the source Build
59
59
  first if you want an editable workspace.
60
60
 
61
+ ## Inspecting Build SDK data
62
+
63
+ `lumine sdk call <namespace.method> '<jsonArgs>'` calls a build's data SDK
64
+ endpoint with your login and prints the response, so you can inspect real data
65
+ and measure latency while building. Run `lumine sdk list` to see callable
66
+ methods. The JSON args are sent as the request body (shapes follow
67
+ `TWINKLE_BUILD_SDK.md`).
68
+
69
+ ```bash
70
+ lumine sdk call aiStories.chapters '{"limit": 5}'
71
+ lumine sdk call aiStories.list '{"difficulty": 1}' --repeat 5 --build 1374
72
+ ```
73
+
74
+ It targets the build in the current workspace, or pass `--build <id>`. Add
75
+ `--repeat <n>` for min/avg/max latency. Output is the raw endpoint response,
76
+ which can differ from a method's `Twinkle.*` SDK return shape — check
77
+ `TWINKLE_BUILD_SDK.md` for SDK return shapes. Methods that change data require
78
+ `--allow-write`.
79
+
61
80
  The CLI checks npm for the latest `@stage5/lumine` version on normal commands.
62
81
  If the installed copy is outdated, it prints an update warning and records the
63
82
  version state in `.twinkle/lumine-project.json` so local agents can tell when
package/bin/lumine.js CHANGED
@@ -83,6 +83,12 @@ lumine save --summary "Describe the change"
83
83
  \`\`\`
84
84
 
85
85
  - Run lumine check before launch when possible.
86
+ - Use \`lumine sdk call <namespace.method> '{...}'\` to inspect real endpoint
87
+ data and measure latency (add --repeat <n>); \`lumine sdk list\` shows
88
+ callable methods. It prints the raw HTTP endpoint response, which can differ
89
+ from a method's Twinkle.* SDK return shape (some wrappers unwrap or rename
90
+ fields) — use ${SDK_REFERENCE_FILE} for SDK return shapes. Write methods
91
+ need --allow-write and mutate real app data.
86
92
  - Owned canonical builds may be published only when the user explicitly asks.
87
93
 
88
94
  ## App Constraints
@@ -133,6 +139,7 @@ const COMMANDS = new Set([
133
139
  "push",
134
140
  "check",
135
141
  "launch",
142
+ "sdk",
136
143
  "help",
137
144
  ]);
138
145
 
@@ -220,6 +227,10 @@ async function main() {
220
227
  await launch(options);
221
228
  return;
222
229
  }
230
+ if (options.command === "sdk") {
231
+ await sdkCommand(options);
232
+ return;
233
+ }
223
234
 
224
235
  printHelp();
225
236
  }
@@ -230,7 +241,13 @@ async function login(options) {
230
241
  url: `${options.apiUrl}/cli/device/start`,
231
242
  body: {
232
243
  clientName: options.clientName,
233
- scopes: ["build:read", "build:write", "build:check", "build:publish"],
244
+ scopes: [
245
+ "build:read",
246
+ "build:write",
247
+ "build:check",
248
+ "build:publish",
249
+ "build:sdk",
250
+ ],
234
251
  },
235
252
  timeoutMs: options.timeoutMs,
236
253
  });
@@ -702,6 +719,490 @@ async function ensureAuth(options) {
702
719
  return await resolveAuth(options);
703
720
  }
704
721
 
722
+ // Build SDK data-API methods callable from the CLI. Method names match the
723
+ // SDK manifest (constants/buildSdkIndex.json in twinkle-api); paths are the
724
+ // HTTP endpoints the Build runtime parent proxies those methods to. The args
725
+ // JSON object is sent verbatim as the POST body, so arg shapes follow
726
+ // TWINKLE_BUILD_SDK.md. Methods that mutate data require --allow-write.
727
+ // Anything not listed can be called with --path api/<endpoint>.
728
+ const SDK_CLI_METHODS = {
729
+ "aiStories.list": { path: "api/content/ai-stories/list", scopes: ["content:read"] },
730
+ "aiStories.chapters": { path: "api/content/ai-stories/chapters", scopes: ["content:read"] },
731
+ "aiStories.search": { path: "api/content/ai-stories/search", scopes: ["content:read"] },
732
+ "aiStories.get": { path: "api/content/ai-story", scopes: ["content:read"] },
733
+ "aiCards.list": { path: "api/content/ai-cards/list", scopes: ["content:read"] },
734
+ "aiCards.search": { path: "api/content/ai-cards/search", scopes: ["content:read"] },
735
+ "aiCards.get": { path: "api/content/ai-card", scopes: ["content:read"] },
736
+ "grammarbles.listQuestions": { path: "api/content/grammarbles/questions", scopes: ["content:read"] },
737
+ "grammarbles.getMyQuestionHistory": { path: "api/content/grammarbles/history", scopes: ["content:read"] },
738
+ "subjects.getMySubjects": { path: "api/content/my-subjects", scopes: ["content:read"] },
739
+ "subjects.search": { path: "api/content/subjects/search", scopes: ["content:read"] },
740
+ "subjects.getSubject": { path: "api/content/subject", scopes: ["content:read"] },
741
+ "subjects.getSubjectComments": { path: "api/content/subject-comments", scopes: ["content:read"] },
742
+ "subjectComments.list": { path: "api/content/subject-comment-list", scopes: ["content:read"] },
743
+ "profileComments.getProfileComments": { path: "api/content/profile-comments", scopes: ["content:read"] },
744
+ "profileComments.getProfileCommentIds": { path: "api/content/profile-comment-ids", scopes: ["content:read"] },
745
+ "profileComments.getCommentsByIds": { path: "api/content/profile-comments-by-ids", scopes: ["content:read"] },
746
+ "profileComments.getProfileCommentCounts": { path: "api/content/profile-comment-counts", scopes: ["content:read"] },
747
+ "privateDb.get": { path: "api/private-db/get", scopes: ["privateDb:read"] },
748
+ "privateDb.list": { path: "api/private-db/list", scopes: ["privateDb:read"] },
749
+ "privateDb.set": { path: "api/private-db/set", scopes: ["privateDb:write"], write: true },
750
+ "privateDb.remove": { path: "api/private-db/delete", scopes: ["privateDb:write"], write: true },
751
+ "sharedDb.getTopics": { path: "api/shared-db/topics", scopes: ["sharedDb:read"] },
752
+ "sharedDb.createTopic": { path: "api/shared-db/topic", scopes: ["sharedDb:write"], write: true },
753
+ "sharedDb.getEntries": { path: "api/shared-db/entries", scopes: ["sharedDb:read"] },
754
+ "sharedDb.addEntry": { path: "api/shared-db/entry", scopes: ["sharedDb:write"], write: true },
755
+ "sharedDb.updateEntry": { path: "api/shared-db/entry/update", scopes: ["sharedDb:write"], write: true },
756
+ "sharedDb.deleteEntry": { path: "api/shared-db/entry/delete", scopes: ["sharedDb:write"], write: true },
757
+ "sharedDb.kv.get": { path: "api/shared-db/kv/get", scopes: ["sharedDb:read"] },
758
+ "sharedDb.kv.list": { path: "api/shared-db/kv/list", scopes: ["sharedDb:read"] },
759
+ // The kv/set endpoint is batch-shaped ({ namespace, items }); kv.set keeps
760
+ // the SDK's single-key signature and maps to one item.
761
+ "sharedDb.kv.set": {
762
+ path: "api/shared-db/kv/set",
763
+ scopes: ["sharedDb:write"],
764
+ write: true,
765
+ mapArgs: (args) => ({
766
+ namespace: args.namespace,
767
+ items: [{ key: args.key, value: args.value }],
768
+ }),
769
+ sdkReshape: "the SDK takes (namespace, key, value) and returns { item }",
770
+ },
771
+ "sharedDb.kv.setMany": { path: "api/shared-db/kv/set", scopes: ["sharedDb:write"], write: true },
772
+ "sharedDb.kv.remove": { path: "api/shared-db/kv/delete", scopes: ["sharedDb:write"], write: true },
773
+ "chat.listRooms": { path: "api/chat/rooms/list", scopes: ["chat:read"] },
774
+ "chat.createRoom": { path: "api/chat/rooms/create", scopes: ["chat:write"], write: true },
775
+ "chat.listMessages": { path: "api/chat/messages/list", scopes: ["chat:read"] },
776
+ "chat.sendMessage": { path: "api/chat/messages/send", scopes: ["chat:write"], write: true },
777
+ "chat.deleteMessage": { path: "api/chat/messages/delete", scopes: ["chat:write"], write: true },
778
+ "users.getUser": {
779
+ path: "api/user",
780
+ scopes: ["user:read"],
781
+ sdkReshape: "the SDK returns the user object directly (unwraps { user })",
782
+ },
783
+ "users.getUsers": { path: "api/users", scopes: ["users:read"] },
784
+ "reflections.getDailyReflections": { path: "api/daily-reflections", scopes: ["dailyReflections:read"] },
785
+ "reminders.list": { path: "api/reminders/list", scopes: ["reminders:read"] },
786
+ // getDue's backend defaults autoAcknowledge to true, which persists writes
787
+ // (lastTriggeredAt, and disabling one-shot reminders). Force read-only
788
+ // probes to autoAcknowledge:false; acknowledging is a write that needs
789
+ // --allow-write.
790
+ "reminders.getDue": {
791
+ path: "api/reminders/due",
792
+ scopes: ["reminders:read"],
793
+ writeWhen: (args) => args.autoAcknowledge === true,
794
+ mapArgs: (args) => ({
795
+ ...args,
796
+ autoAcknowledge: args.autoAcknowledge === true,
797
+ }),
798
+ },
799
+ "reminders.create": { path: "api/reminders/create", scopes: ["reminders:write"], write: true },
800
+ "reminders.update": { path: "api/reminders/update", scopes: ["reminders:write"], write: true },
801
+ "reminders.remove": { path: "api/reminders/delete", scopes: ["reminders:write"], write: true },
802
+ "files.list": {
803
+ path: "api/files/list",
804
+ scopes: ["files:read"],
805
+ sdkReshape: "the SDK returns { assets, usage }",
806
+ },
807
+ "files.delete": {
808
+ path: "api/files/delete",
809
+ scopes: ["files:write"],
810
+ write: true,
811
+ sdkReshape: "the SDK returns { success, usage }",
812
+ },
813
+ "notifications.getSubscription": { path: "api/notifications/subscription", scopes: ["notifications:read"] },
814
+ "notifications.subscribe": { path: "api/notifications/subscription/subscribe", scopes: ["notifications:write"], write: true },
815
+ "notifications.unsubscribe": { path: "api/notifications/subscription/unsubscribe", scopes: ["notifications:write"], write: true },
816
+ "notifications.subscribeMany": { path: "api/notifications/subscriptions/subscribe", scopes: ["notifications:write"], write: true },
817
+ "notifications.unsubscribeMany": { path: "api/notifications/subscriptions/unsubscribe", scopes: ["notifications:write"], write: true },
818
+ "notifications.notifySubscribers": { path: "api/notifications/notify-subscribers", scopes: ["notifications:emit"], write: true },
819
+ "notifications.getSubjectUpdateSubscription": { path: "api/notifications/subject-update-subscription", scopes: ["notifications:read"] },
820
+ "notifications.subscribeToSubjectUpdates": { path: "api/notifications/subject-update-subscription/subscribe", scopes: ["notifications:write"], write: true },
821
+ "notifications.unsubscribeFromSubjectUpdates": { path: "api/notifications/subject-update-subscription/unsubscribe", scopes: ["notifications:write"], write: true },
822
+ // Leaderboards use the public leaderboard routes (regular login auth, no
823
+ // build API token). args.boardKey selects the board; remaining args are
824
+ // query params (get) or the POST body (submit).
825
+ "leaderboards.get": { special: "leaderboardGet" },
826
+ "leaderboards.submit": { special: "leaderboardSubmit", write: true },
827
+ // userDb (the viewer's private SQLite) lives at /viewer-db/*, which uses
828
+ // session auth directly — no build API token. query reads, exec mutates.
829
+ // Body is { sql, params }. The backend's query route accepts any
830
+ // row-returning statement, including INSERT/UPDATE/DELETE ... RETURNING,
831
+ // so SQL that mutates is gated behind --allow-write here too.
832
+ "userDb.query": { special: "userDbQuery", writeWhen: (args) => sqlMutates(args.sql) },
833
+ "userDb.exec": { special: "userDbExec", write: true },
834
+ };
835
+
836
+ // Heuristic write-detection for raw SQL. SELECT/EXPLAIN/VALUES cannot mutate;
837
+ // a WITH (CTE) can wrap DML; everything else is treated as write-capable.
838
+ // Errs toward over-gating (safe) rather than under-gating.
839
+ function sqlMutates(sql) {
840
+ const normalized = String(sql || "")
841
+ .trim()
842
+ .replace(/^\(+/, "");
843
+ const leadingKeyword = (normalized.match(/^\s*([a-z]+)/i)?.[1] || "").toUpperCase();
844
+ if (
845
+ leadingKeyword === "SELECT" ||
846
+ leadingKeyword === "EXPLAIN" ||
847
+ leadingKeyword === "VALUES"
848
+ ) {
849
+ return false;
850
+ }
851
+ if (leadingKeyword === "WITH") {
852
+ return /\b(INSERT|UPDATE|DELETE|REPLACE)\b/i.test(normalized);
853
+ }
854
+ return true;
855
+ }
856
+
857
+ // Reverse index of curated endpoint paths -> method name(s). Used to reject
858
+ // raw --path calls to curated endpoints: --path is for endpoints not in the
859
+ // list, and routing a curated endpoint through --path would strip its
860
+ // write/writeWhen/mapArgs safety handling (e.g. api/reminders/due defaults
861
+ // autoAcknowledge to true and mutates under a read scope).
862
+ const SDK_CLI_METHOD_NAMES_BY_PATH = (() => {
863
+ const byPath = new Map();
864
+ for (const [name, entry] of Object.entries(SDK_CLI_METHODS)) {
865
+ if (!entry.path) continue;
866
+ if (!byPath.has(entry.path)) byPath.set(entry.path, []);
867
+ byPath.get(entry.path).push(name);
868
+ }
869
+ return byPath;
870
+ })();
871
+
872
+ // Default token scopes for --path calls. Never fall through to the server's
873
+ // default scope set: it includes write/emit scopes, which must not be minted
874
+ // without --allow-write.
875
+ const SDK_CLI_READ_SCOPES = [
876
+ "files:read",
877
+ "user:read",
878
+ "users:read",
879
+ "dailyReflections:read",
880
+ "content:read",
881
+ "sharedDb:read",
882
+ "privateDb:read",
883
+ "chat:read",
884
+ "notifications:read",
885
+ "reminders:read",
886
+ ];
887
+
888
+ function isWriteCapableScope(scope) {
889
+ return /:(write|emit)$/.test(String(scope));
890
+ }
891
+
892
+ async function sdkCommand(options) {
893
+ const subcommand = String(options.positional[0] || "list");
894
+ if (subcommand === "list") {
895
+ printSdkMethodList();
896
+ return;
897
+ }
898
+ if (subcommand !== "call") {
899
+ throw new Error(
900
+ 'Usage: lumine sdk list | lumine sdk call <namespace.method> [jsonArgs]',
901
+ );
902
+ }
903
+ await sdkCall(options);
904
+ }
905
+
906
+ function printSdkMethodList() {
907
+ console.log(
908
+ "Callable Build endpoints (args JSON = request body). Prints the raw\n" +
909
+ "endpoint response, which can differ from the SDK return shape (*).\n",
910
+ );
911
+ const byNamespace = new Map();
912
+ for (const [name, entry] of Object.entries(SDK_CLI_METHODS)) {
913
+ const namespace = name.split(".")[0];
914
+ if (!byNamespace.has(namespace)) byNamespace.set(namespace, []);
915
+ byNamespace.get(namespace).push({ name, entry });
916
+ }
917
+ for (const [namespace, methods] of byNamespace) {
918
+ console.log(` ${namespace}`);
919
+ for (const { name, entry } of methods) {
920
+ const writeTag = entry.write ? " [write: needs --allow-write]" : "";
921
+ const reshapeTag = entry.sdkReshape ? " *" : "";
922
+ const pathText = entry.path || `(${entry.special})`;
923
+ console.log(
924
+ ` ${name.padEnd(44)} ${pathText}${writeTag}${reshapeTag}`,
925
+ );
926
+ }
927
+ }
928
+ console.log(`
929
+ Usage:
930
+ lumine sdk call aiStories.chapters '{"limit": 5}'
931
+ lumine sdk call privateDb.set '{"key": "k", "value": {"a": 1}}' --allow-write
932
+ lumine sdk call leaderboards.get '{"boardKey": "default", "limit": 10}'
933
+ lumine sdk call aiStories.list '{"difficulty": 1}' --repeat 5 --build 1374
934
+ lumine sdk call userDb.query '{"sql": "SELECT * FROM sqlite_master"}'
935
+
936
+ Request-body (args) shapes follow TWINKLE_BUILD_SDK.md. Output is the raw
937
+ endpoint response; (*) methods are reshaped by their Twinkle.* SDK wrapper, so
938
+ check TWINKLE_BUILD_SDK.md for the real SDK return shape. Add --repeat <n> for
939
+ latency stats, --build <id> to target a build outside the current workspace.
940
+ Use --path api/<endpoint> only for endpoints not listed above.`);
941
+ }
942
+
943
+ async function sdkCall(options) {
944
+ const usingRawPath = Boolean(options.sdkPath);
945
+ const methodName = usingRawPath ? "" : String(options.positional[1] || "");
946
+ const argsText = String(
947
+ (usingRawPath ? options.positional[1] : options.positional[2]) || "",
948
+ ).trim();
949
+
950
+ let args = {};
951
+ if (argsText) {
952
+ try {
953
+ args = JSON.parse(argsText);
954
+ } catch (error) {
955
+ throw new Error(`Args must be valid JSON: ${error.message}`);
956
+ }
957
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
958
+ throw new Error("Args must be a JSON object, e.g. '{\"limit\": 5}'.");
959
+ }
960
+ }
961
+
962
+ let endpoint;
963
+ if (usingRawPath) {
964
+ let normalizedPath = String(options.sdkPath).replace(/^\/+/, "");
965
+ if (!/^api\/[a-zA-Z0-9/_-]+$/.test(normalizedPath)) {
966
+ throw new Error("--path must look like api/<endpoint> (no query string).");
967
+ }
968
+ // Normalize the way Express routes (case-insensitive, non-strict, slashes
969
+ // collapsed) so route-equivalent variants like api/Token or
970
+ // api/reminders/due/ cannot dodge the guards below. Guard and call the
971
+ // same normalized path so the checked string is the executed string.
972
+ normalizedPath = normalizedPath
973
+ .toLowerCase()
974
+ .replace(/\/{2,}/g, "/")
975
+ .replace(/\/+$/, "");
976
+ // api/token is control-plane: the CLI mints the build API token itself,
977
+ // and that endpoint's response is a token whose scopes come from the
978
+ // request body, not from the CLI mint — so the write gate cannot see
979
+ // them. Calling it as a data endpoint would hand back a write-capable
980
+ // token without --allow-write. It is not a data method; reject it.
981
+ if (/^api\/token(\/|$)/.test(normalizedPath)) {
982
+ throw new Error(
983
+ "api/token is not a callable SDK endpoint. The CLI mints build API " +
984
+ "tokens internally for sdk calls.",
985
+ );
986
+ }
987
+ // --path is for endpoints not in the curated list. Reject curated paths
988
+ // so their read/write safety handling cannot be bypassed via --path.
989
+ const curatedMethodNames = SDK_CLI_METHOD_NAMES_BY_PATH.get(normalizedPath);
990
+ if (curatedMethodNames) {
991
+ throw new Error(
992
+ `${normalizedPath} is a curated SDK endpoint. Call it as ` +
993
+ `${curatedMethodNames.join(" or ")} (applies the correct read/` +
994
+ "write handling). --path is only for endpoints not in `lumine sdk list`.",
995
+ );
996
+ }
997
+ endpoint = { path: normalizedPath, scopes: SDK_CLI_READ_SCOPES };
998
+ } else {
999
+ if (!methodName) {
1000
+ throw new Error(
1001
+ 'Usage: lumine sdk call <namespace.method> [jsonArgs]. Run `lumine sdk list`.',
1002
+ );
1003
+ }
1004
+ endpoint = SDK_CLI_METHODS[methodName];
1005
+ if (!endpoint) {
1006
+ throw new Error(
1007
+ `Unknown SDK method "${methodName}". Run \`lumine sdk list\`, or call ` +
1008
+ "the endpoint directly with --path api/<endpoint>.",
1009
+ );
1010
+ }
1011
+ }
1012
+
1013
+ // Token-capability gate: a write-capable build API token must never be
1014
+ // minted (and write-tagged methods never called) without --allow-write,
1015
+ // regardless of whether the call came from the curated table, --path, or a
1016
+ // --scopes override.
1017
+ const requestedScopes = options.sdkScopes.length
1018
+ ? options.sdkScopes
1019
+ : endpoint.scopes || [];
1020
+ const writeScopes = requestedScopes.filter(isWriteCapableScope);
1021
+ // writeWhen covers endpoints that mutate under a read scope depending on
1022
+ // their args (e.g. reminders.getDue acknowledging due reminders).
1023
+ const writeByArgs =
1024
+ typeof endpoint.writeWhen === "function" && endpoint.writeWhen(args);
1025
+ const writeCapable =
1026
+ Boolean(endpoint.write) || writeScopes.length > 0 || writeByArgs;
1027
+ if (writeCapable && !options.allowWrite) {
1028
+ const reason = writeScopes.length
1029
+ ? `write scopes: ${writeScopes.join(", ")}`
1030
+ : "this call writes app data";
1031
+ throw new Error(
1032
+ `${methodName || endpoint.path} can mutate real app data ` +
1033
+ `(${reason}). Re-run with --allow-write.`,
1034
+ );
1035
+ }
1036
+
1037
+ // Resolve the build target (local-only: --build or workspace metadata)
1038
+ // before any auth side effects, so invalid local input fails immediately
1039
+ // instead of after starting the device-login flow.
1040
+ const buildId = await resolveSdkBuildId(options);
1041
+ const auth = await ensureAuth(options);
1042
+ await assertAuthScope({ options, auth, scope: "build:sdk" });
1043
+ const attempts = [];
1044
+ let lastResult = null;
1045
+
1046
+ if (endpoint.special === "leaderboardGet" || endpoint.special === "leaderboardSubmit") {
1047
+ const boardKey = String(args.boardKey || "default");
1048
+ const rest = { ...args };
1049
+ delete rest.boardKey;
1050
+ for (let attempt = 0; attempt < options.repeat; attempt += 1) {
1051
+ if (endpoint.special === "leaderboardGet") {
1052
+ const url = new URL(
1053
+ `${options.apiUrl}/build/${buildId}/leaderboards/${encodeURIComponent(boardKey)}`,
1054
+ );
1055
+ for (const [key, value] of Object.entries(rest)) {
1056
+ if (value !== null && value !== undefined) {
1057
+ url.searchParams.set(key, String(value));
1058
+ }
1059
+ }
1060
+ lastResult = await executeSdkHttpCall({
1061
+ options,
1062
+ method: "GET",
1063
+ url: url.toString(),
1064
+ authToken: auth.token,
1065
+ });
1066
+ } else {
1067
+ lastResult = await executeSdkHttpCall({
1068
+ options,
1069
+ method: "POST",
1070
+ url: `${options.apiUrl}/build/${buildId}/leaderboards/${encodeURIComponent(boardKey)}/submit`,
1071
+ authToken: auth.token,
1072
+ body: rest,
1073
+ });
1074
+ }
1075
+ attempts.push(lastResult);
1076
+ printSdkAttemptLine(attempts.length, lastResult);
1077
+ }
1078
+ } else if (
1079
+ endpoint.special === "userDbQuery" ||
1080
+ endpoint.special === "userDbExec"
1081
+ ) {
1082
+ // viewer-db uses session auth (no build API token). Body is { sql, params }.
1083
+ const route = endpoint.special === "userDbExec" ? "exec" : "query";
1084
+ const body = { sql: args.sql, params: args.params };
1085
+ for (let attempt = 0; attempt < options.repeat; attempt += 1) {
1086
+ lastResult = await executeSdkHttpCall({
1087
+ options,
1088
+ method: "POST",
1089
+ url: `${options.apiUrl}/build/${buildId}/viewer-db/${route}`,
1090
+ authToken: auth.token,
1091
+ body,
1092
+ });
1093
+ attempts.push(lastResult);
1094
+ printSdkAttemptLine(attempts.length, lastResult);
1095
+ }
1096
+ } else {
1097
+ // Always request explicit scopes: an empty scopes body makes the server
1098
+ // fall back to its default scope set, which is write-capable.
1099
+ const scopes = requestedScopes.length
1100
+ ? requestedScopes
1101
+ : SDK_CLI_READ_SCOPES;
1102
+ const tokenStartedAt = Date.now();
1103
+ const tokenResult = await requestJson({
1104
+ method: "POST",
1105
+ url: `${options.apiUrl}/build/${buildId}/api/token`,
1106
+ authToken: auth.token,
1107
+ body: { scopes },
1108
+ timeoutMs: options.timeoutMs,
1109
+ });
1110
+ console.error(
1111
+ `token minted in ${Date.now() - tokenStartedAt}ms (scopes: ${
1112
+ (tokenResult.scopes || []).join(", ") || "default"
1113
+ })`,
1114
+ );
1115
+ const body = endpoint.mapArgs ? endpoint.mapArgs(args) : args;
1116
+ for (let attempt = 0; attempt < options.repeat; attempt += 1) {
1117
+ lastResult = await executeSdkHttpCall({
1118
+ options,
1119
+ method: "POST",
1120
+ url: `${options.apiUrl}/build/${buildId}/${endpoint.path}`,
1121
+ authToken: auth.token,
1122
+ body,
1123
+ buildApiToken: tokenResult.token,
1124
+ });
1125
+ attempts.push(lastResult);
1126
+ printSdkAttemptLine(attempts.length, lastResult);
1127
+ }
1128
+ }
1129
+
1130
+ if (options.repeat > 1) {
1131
+ const timings = attempts.map((entry) => entry.ms);
1132
+ const total = timings.reduce((sum, ms) => sum + ms, 0);
1133
+ console.error(
1134
+ `latency over ${timings.length} calls: min ${Math.min(...timings)}ms · ` +
1135
+ `avg ${Math.round(total / timings.length)}ms · max ${Math.max(...timings)}ms`,
1136
+ );
1137
+ }
1138
+
1139
+ // The printed JSON is the raw endpoint response. Some SDK wrappers reshape
1140
+ // it, so warn precisely where the SDK return shape differs.
1141
+ if (endpoint && endpoint.sdkReshape) {
1142
+ console.error(
1143
+ `note: raw endpoint response below — ${methodName}'s SDK wrapper ` +
1144
+ `reshapes it (${endpoint.sdkReshape}). See TWINKLE_BUILD_SDK.md.`,
1145
+ );
1146
+ }
1147
+ if (lastResult) {
1148
+ console.log(JSON.stringify(lastResult.data ?? lastResult.text, null, 2));
1149
+ }
1150
+ if (lastResult && !lastResult.ok) {
1151
+ process.exitCode = 1;
1152
+ }
1153
+ }
1154
+
1155
+ function printSdkAttemptLine(attemptNumber, result) {
1156
+ console.error(
1157
+ `#${attemptNumber} ${result.status}${result.ok ? " OK" : ""} · ` +
1158
+ `${result.bytes.toLocaleString()} bytes · ${result.ms}ms`,
1159
+ );
1160
+ }
1161
+
1162
+ async function executeSdkHttpCall({
1163
+ options,
1164
+ method,
1165
+ url,
1166
+ authToken,
1167
+ body,
1168
+ buildApiToken,
1169
+ }) {
1170
+ const startedAt = Date.now();
1171
+ const response = await request({
1172
+ method,
1173
+ url,
1174
+ authToken,
1175
+ body,
1176
+ timeoutMs: options.timeoutMs,
1177
+ headers: buildApiToken ? { "x-build-api-token": buildApiToken } : {},
1178
+ });
1179
+ const text = await response.text();
1180
+ return {
1181
+ ok: response.ok,
1182
+ status: response.status,
1183
+ ms: Date.now() - startedAt,
1184
+ bytes: Buffer.byteLength(text),
1185
+ data: parseJson(text),
1186
+ text,
1187
+ };
1188
+ }
1189
+
1190
+ async function resolveSdkBuildId(options) {
1191
+ if (options.buildIdFlag) {
1192
+ const buildId = resolveBuildId(options.buildIdFlag);
1193
+ if (buildId > 0) return buildId;
1194
+ throw new Error(`Invalid --build value: ${options.buildIdFlag}`);
1195
+ }
1196
+ const localProject = await findLocalProjectMetadata(
1197
+ options.dir ? path.resolve(options.dir) : process.cwd(),
1198
+ );
1199
+ const buildId = Number(localProject?.metadata?.buildId || 0);
1200
+ if (buildId > 0) return buildId;
1201
+ throw new Error(
1202
+ "No build workspace found. Run from a pulled workspace, pass --dir, or pass --build <id>.",
1203
+ );
1204
+ }
1205
+
705
1206
  async function listBuilds({ options, auth }) {
706
1207
  const url = new URL(`${options.apiUrl}/cli/builds`);
707
1208
  url.searchParams.set("limit", String(options.limit));
@@ -980,7 +1481,7 @@ async function assertAuthScope({ options, auth, scope }) {
980
1481
  const scopes = Array.isArray(session.scopes) ? session.scopes : [];
981
1482
  if (!scopes.includes(scope)) {
982
1483
  throw new Error(
983
- `Saved login is missing ${scope}. Run \`lumine login\` again to approve file saves.`,
1484
+ `Saved login is missing ${scope}. Run \`lumine login\` again to grant it.`,
984
1485
  );
985
1486
  }
986
1487
  }
@@ -1769,7 +2270,14 @@ async function probeUrl({ url, authToken, timeoutMs }) {
1769
2270
  };
1770
2271
  }
1771
2272
 
1772
- async function request({ method = "GET", url, authToken, body, timeoutMs }) {
2273
+ async function request({
2274
+ method = "GET",
2275
+ url,
2276
+ authToken,
2277
+ body,
2278
+ timeoutMs,
2279
+ headers = {},
2280
+ }) {
1773
2281
  const controller = new AbortController();
1774
2282
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
1775
2283
  try {
@@ -1778,6 +2286,7 @@ async function request({ method = "GET", url, authToken, body, timeoutMs }) {
1778
2286
  headers: {
1779
2287
  ...(authToken ? { authorization: authorizationHeader(authToken) } : {}),
1780
2288
  ...(body ? { "content-type": "application/json" } : {}),
2289
+ ...headers,
1781
2290
  },
1782
2291
  body: body ? JSON.stringify(body) : undefined,
1783
2292
  signal: controller.signal,
@@ -1919,6 +2428,7 @@ function parseArgs(args) {
1919
2428
  "publish",
1920
2429
  "save",
1921
2430
  "noUpdateCheck",
2431
+ "allowWrite",
1922
2432
  ]);
1923
2433
 
1924
2434
  for (let i = 0; i < rest.length; i += 1) {
@@ -1945,6 +2455,15 @@ function parseArgs(args) {
1945
2455
 
1946
2456
  return {
1947
2457
  command,
2458
+ positional,
2459
+ repeat: Math.min(Math.max(Math.floor(Number(raw.repeat) || 1), 1), 20),
2460
+ allowWrite: parseBoolean(raw.allowWrite, false),
2461
+ sdkPath: raw.path ? String(raw.path) : "",
2462
+ sdkScopes: String(raw.scopes || "")
2463
+ .split(",")
2464
+ .map((scope) => scope.trim())
2465
+ .filter(Boolean),
2466
+ buildIdFlag: raw.build ? String(raw.build) : "",
1948
2467
  target: raw.url || raw.target || positional[0] || "",
1949
2468
  title:
1950
2469
  String(
@@ -2279,6 +2798,8 @@ function printHelp() {
2279
2798
  lumine save
2280
2799
  lumine check [twinkle-build-url]
2281
2800
  lumine launch [twinkle-build-url]
2801
+ lumine sdk list
2802
+ lumine sdk call <namespace.method> [jsonArgs]
2282
2803
 
2283
2804
  Examples:
2284
2805
  npx @stage5/lumine@latest
@@ -2295,6 +2816,9 @@ Examples:
2295
2816
  npx @stage5/lumine@latest save --publish
2296
2817
  npx @stage5/lumine@latest launch --save
2297
2818
  npx @stage5/lumine@latest launch https://www.twin-kle.com/app/123
2819
+ npx @stage5/lumine@latest sdk call aiStories.chapters '{"limit": 5}'
2820
+ npx @stage5/lumine@latest sdk call privateDb.get '{"key": "prefs"}' --build 1374
2821
+ npx @stage5/lumine@latest sdk call aiStories.list '{"difficulty": 1}' --repeat 5
2298
2822
 
2299
2823
  Options:
2300
2824
  --api-url <url> Twinkle API origin
@@ -2313,5 +2837,10 @@ Options:
2313
2837
  --limit <number> Number of projects to show
2314
2838
  --no-update-check Skip the npm latest-version check
2315
2839
  --no-open Print the approval URL without opening a browser
2840
+ --build <id> Build id for sdk calls outside a workspace
2841
+ --repeat <n> Repeat an sdk call (1-20) and print latency stats
2842
+ --allow-write Permit sdk methods that mutate app data
2843
+ --path <api/...> Call an sdk endpoint not in the curated list
2844
+ --scopes <a,b> Override requested build API token scopes
2316
2845
  `);
2317
2846
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.26.1
4
- Updated: 2026-06-08
5
- Generated: 2026-06-08T05:37:13.752Z
3
+ Version: 1.26.2
4
+ Updated: 2026-06-09
5
+ Generated: 2026-06-09T01:00:18.637Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -17,7 +17,7 @@ Generated: 2026-06-08T05:37:13.752Z
17
17
  - Use Twinkle.aiStories for read-only existing AI Story galleries, readers, quizzes, topic chapter indexes, and remix tools.
18
18
  - Use Twinkle.grammarbles for public Grammarbles question-bank trainer apps and optional signed-in viewer attempt-history filtering.
19
19
  - Use Twinkle.chess for chess engine play and analysis; app code still owns chess rules, legal moves, board state, and UI.
20
- - Use Twinkle.world for realtime multiplayer rooms, avatar presence, movement, emotes, and lightweight actions; keep durable MMO state in sharedDb/privateDb.
20
+ - Use Twinkle.world for realtime multiplayer rooms, avatar presence, movement, emotes, and lightweight actions; world sessions are disposable and durable MMO state belongs in sharedDb/privateDb.
21
21
  - Use Twinkle.characters.chat for real Zero/Ciel NPC dialogue with shared room context and AI Energy-aware thinking modes.
22
22
  - Twinkle.ai.chat history entries must use { role, content }; map local message.text fields to content before passing history.
23
23
 
@@ -316,6 +316,28 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
316
316
  - Example: const world = await Twinkle.world.join({ roomKey: 'town-square', presence: { x: 0, y: 0, z: 0, facing: 'south' }, player: { name: avatarName } });
317
317
  world.subscribe((event) => updateRemotePlayers(event.players));
318
318
  world.updatePresence({ x, y, z, facing });
319
+ - isRecoverableSessionError(error) | scopes: none
320
+ - Returns: boolean
321
+ - Return true when a world request error is expected to be handled by app code instead of crashing.
322
+ - Example: try {
323
+ await world.updatePresence({ x, y, z, facing });
324
+ } catch (error) {
325
+ if (Twinkle.world.isSessionEndedError(error)) {
326
+ world = null;
327
+ scheduleReconnect();
328
+ } else if (Twinkle.world.isRecoverableSessionError(error)) {
329
+ // Drop this transient presence update and keep the current handle.
330
+ } else {
331
+ throw error;
332
+ }
333
+ }
334
+ - isSessionEndedError(error) | scopes: none
335
+ - Returns: boolean
336
+ - Return true when a world request error means the current session handle is stale and app code should reconnect with a fresh Twinkle.world.join call.
337
+ - Example: if (Twinkle.world.isSessionEndedError(error)) {
338
+ world = null;
339
+ scheduleReconnect();
340
+ }
319
341
  - leaveAll() | scopes: none
320
342
  - Returns: void
321
343
  - Leave every active world session in the current iframe.
@@ -542,27 +564,64 @@ await Twinkle.chat.sendMessage('lobby', 'hello');
542
564
  ```
543
565
 
544
566
  ### Realtime MMO town room
545
- Use Twinkle.world for live avatar presence and lightweight room actions, while durable state like inventory and quests stays in sharedDb/privateDb.
567
+ Use Twinkle.world for live avatar presence and lightweight room actions, recover stale session handles, and keep durable state like inventory and quests in sharedDb/privateDb.
546
568
  Keywords: multiplayer, mmo, town, presence, avatars, movement, three.js, realtime
547
569
 
548
570
  ```js
549
- const world = await Twinkle.world.join({
550
- worldKey: 'town',
551
- roomKey: 'square',
552
- presence: { x: 0, y: 0, z: 0, facing: 'south', animation: 'idle' },
553
- player: { name: avatarName }
554
- });
571
+ let world = null;
572
+ let reconnectTimer = 0;
573
+
574
+ async function connectWorld() {
575
+ if (world) return world;
576
+ world = await Twinkle.world.join({
577
+ worldKey: 'town',
578
+ roomKey: 'square',
579
+ presence: { x: 0, y: 0, z: 0, facing: 'south', animation: 'idle' },
580
+ player: { name: avatarName }
581
+ });
582
+
583
+ world.subscribe((event) => {
584
+ renderPlayers(event.players);
585
+ if (event.type === 'session.ended') {
586
+ handleWorldDrop();
587
+ }
588
+ if (event.type === 'action.received' && event.action?.type === 'emote') {
589
+ showEmote(event.sessionId, event.action.data.emote);
590
+ }
591
+ });
592
+ return world;
593
+ }
555
594
 
556
- world.subscribe((event) => {
557
- renderPlayers(event.players);
558
- if (event.type === 'action.received' && event.action?.type === 'emote') {
559
- showEmote(event.sessionId, event.action.data.emote);
595
+ function handleWorldDrop() {
596
+ world = null;
597
+ if (!reconnectTimer) {
598
+ reconnectTimer = setTimeout(() => {
599
+ reconnectTimer = 0;
600
+ connectWorld().catch(handleWorldDrop);
601
+ }, 1000);
560
602
  }
561
- });
603
+ }
604
+
605
+ async function syncPresence() {
606
+ try {
607
+ const session = await connectWorld();
608
+ // Throttle this in the game loop, for example 5-15 times per second.
609
+ await session.updatePresence({ x: player.x, y: player.y, z: player.z, facing });
610
+ } catch (error) {
611
+ if (Twinkle.world.isSessionEndedError(error)) {
612
+ handleWorldDrop();
613
+ return;
614
+ }
615
+ if (Twinkle.world.isRecoverableSessionError(error)) {
616
+ // Drop this transient presence update and keep the current handle.
617
+ return;
618
+ }
619
+ throw error;
620
+ }
621
+ }
562
622
 
563
- // Throttle this in the game loop, for example 5-15 times per second.
564
- await world.updatePresence({ x: player.x, y: player.y, z: player.z, facing });
565
- await world.send('emote', { emote: 'wave' });
623
+ await connectWorld();
624
+ await syncPresence();
566
625
  ```
567
626
 
568
627
  ### Play chess against the computer