@stage5/lumine 0.1.10 → 0.2.1
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 +19 -0
- package/bin/lumine.js +532 -3
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +297 -9
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: [
|
|
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
|
|
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({
|
|
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
|
}
|