@tinycloud/cli 0.4.8-beta.9 → 0.5.0-beta.11

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
@@ -1,7 +1,11 @@
1
1
  // src/index.ts
2
- import { readFileSync as readFileSync2 } from "fs";
2
+ import { readFileSync as readFileSync3 } from "fs";
3
3
  import { Command } from "commander";
4
4
 
5
+ // src/output/errors.ts
6
+ import { readFileSync, readdirSync } from "fs";
7
+ import { join as join2 } from "path";
8
+
5
9
  // src/config/constants.ts
6
10
  import { homedir } from "os";
7
11
  import { join } from "path";
@@ -9,6 +13,7 @@ var CONFIG_DIR = join(homedir(), ".tinycloud");
9
13
  var PROFILES_DIR = join(CONFIG_DIR, "profiles");
10
14
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
11
15
  var DEFAULT_HOST = "https://node.tinycloud.xyz";
16
+ var DEFAULT_OPENKEY_HOST = "https://openkey.so";
12
17
  var DEFAULT_PROFILE = "default";
13
18
  var DEFAULT_CHAIN_ID = 1;
14
19
  var ExitCode = {
@@ -56,16 +61,24 @@ var theme = {
56
61
  function outputJson(data) {
57
62
  process.stdout.write(JSON.stringify(data, null, 2) + "\n");
58
63
  }
59
- function outputError(code, message) {
64
+ function outputError(code, message, hint) {
60
65
  if (isInteractive()) {
61
66
  process.stderr.write(
62
67
  `${theme.error("\u2717")} ${theme.label(code)}: ${message}
63
68
  `
64
69
  );
70
+ if (hint) {
71
+ for (const line of hint.split("\n")) {
72
+ process.stderr.write(` ${theme.hint(line)}
73
+ `);
74
+ }
75
+ }
65
76
  } else {
66
- process.stderr.write(
67
- JSON.stringify({ error: { code, message } }, null, 2) + "\n"
68
- );
77
+ const payload = {
78
+ error: { code, message }
79
+ };
80
+ if (hint) payload.error.hint = hint;
81
+ process.stderr.write(JSON.stringify(payload, null, 2) + "\n");
69
82
  }
70
83
  }
71
84
  function isInteractive() {
@@ -130,11 +143,16 @@ function formatTimeAgo(date) {
130
143
  }
131
144
 
132
145
  // src/output/errors.ts
146
+ var activeProfileName;
147
+ function setActiveProfileName(name) {
148
+ activeProfileName = name;
149
+ }
133
150
  var CLIError = class extends Error {
134
- constructor(code, message, exitCode = ExitCode.ERROR) {
151
+ constructor(code, message, exitCode = ExitCode.ERROR, metadata) {
135
152
  super(message);
136
153
  this.code = code;
137
154
  this.exitCode = exitCode;
155
+ this.metadata = metadata;
138
156
  this.name = "CLIError";
139
157
  }
140
158
  };
@@ -157,9 +175,76 @@ function wrapError(error) {
157
175
  }
158
176
  function handleError(error) {
159
177
  const cliError = wrapError(error);
160
- outputError(cliError.code, cliError.message);
178
+ const hint = buildAuthHint(cliError) ?? (cliError.code === "NETWORK_ERROR" ? buildNetworkHint() : void 0);
179
+ outputError(cliError.code, cliError.message, hint);
161
180
  process.exit(cliError.exitCode);
162
181
  }
182
+ function buildAuthHint(error) {
183
+ const resource = error.metadata?.resource;
184
+ const requiredAction = error.metadata?.requiredAction;
185
+ if (typeof resource !== "string" || typeof requiredAction !== "string") {
186
+ return void 0;
187
+ }
188
+ const spec = capSpecFromAuthMeta(resource, requiredAction);
189
+ if (!spec) return void 0;
190
+ return [
191
+ "The active session is missing a TinyCloud capability.",
192
+ `Request it with: tc auth request --cap "${spec}"`,
193
+ "Then retry the original command."
194
+ ].join("\n");
195
+ }
196
+ function capSpecFromAuthMeta(resource, action) {
197
+ const slash = resource.indexOf("/");
198
+ if (slash <= 0 || slash === resource.length - 1) return void 0;
199
+ const spaceUri = resource.slice(0, slash);
200
+ const rest = resource.slice(slash + 1);
201
+ const nextSlash = rest.indexOf("/");
202
+ if (nextSlash <= 0) return void 0;
203
+ const serviceShort = rest.slice(0, nextSlash);
204
+ const path = rest.slice(nextSlash + 1);
205
+ const actionName = action.includes("/") ? action.slice(action.indexOf("/") + 1) : action;
206
+ const spaceName = spaceUri.startsWith("tinycloud:") ? spaceUri.slice(spaceUri.lastIndexOf(":") + 1) : spaceUri;
207
+ return `tinycloud.${serviceShort}:${spaceName}:${path}:${actionName}`;
208
+ }
209
+ function buildNetworkHint() {
210
+ const readHost = (name) => {
211
+ try {
212
+ const raw = readFileSync(join2(PROFILES_DIR, name, "profile.json"), "utf8");
213
+ return JSON.parse(raw).host;
214
+ } catch {
215
+ return void 0;
216
+ }
217
+ };
218
+ let activeName = activeProfileName ?? process.env.TC_PROFILE ?? DEFAULT_PROFILE;
219
+ if (!activeProfileName) {
220
+ try {
221
+ const cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
222
+ activeName = process.env.TC_PROFILE ?? cfg.defaultProfile ?? DEFAULT_PROFILE;
223
+ } catch {
224
+ }
225
+ }
226
+ let names;
227
+ try {
228
+ names = readdirSync(PROFILES_DIR);
229
+ } catch {
230
+ return void 0;
231
+ }
232
+ const activeHost = readHost(activeName);
233
+ const others = names.filter((n) => n !== activeName).map((n) => ({ name: n, host: readHost(n) })).filter((p) => Boolean(p.host));
234
+ const lines = [];
235
+ lines.push(activeHost ? `Active profile "${activeName}" \u2192 ${activeHost}` : `Active profile "${activeName}"`);
236
+ if (others.length === 0) {
237
+ lines.push(`No other profiles configured. Run \`tc profile create <name>\` or \`tc init\`.`);
238
+ } else {
239
+ lines.push(`Switch to a reachable profile:`);
240
+ const longest = Math.max(...others.map((p) => p.name.length));
241
+ for (const { name, host } of others) {
242
+ lines.push(` tc profile switch ${name.padEnd(longest)} # ${host}`);
243
+ }
244
+ }
245
+ lines.push(`Or override per-command with --host or TC_HOST.`);
246
+ return lines.join("\n");
247
+ }
163
248
 
164
249
  // src/output/taglines.ts
165
250
  var HOLIDAY_TAGLINES = [
@@ -253,7 +338,7 @@ function emitBanner(version2) {
253
338
  }
254
339
 
255
340
  // src/config/profiles.ts
256
- import { join as join2 } from "path";
341
+ import { join as join3 } from "path";
257
342
  import { rm as rm2 } from "fs/promises";
258
343
 
259
344
  // src/config/storage.ts
@@ -337,7 +422,7 @@ var ProfileManager = class _ProfileManager {
337
422
  * Throws CLIError if the profile doesn't exist.
338
423
  */
339
424
  static async getProfile(name) {
340
- const profilePath = join2(PROFILES_DIR, name, "profile.json");
425
+ const profilePath = join3(PROFILES_DIR, name, "profile.json");
341
426
  const profile = await readJson(profilePath);
342
427
  if (!profile) {
343
428
  throw new CLIError(
@@ -351,15 +436,15 @@ var ProfileManager = class _ProfileManager {
351
436
  * Saves a profile config, creating the profile directory if needed.
352
437
  */
353
438
  static async setProfile(name, data) {
354
- const profileDir = join2(PROFILES_DIR, name);
439
+ const profileDir = join3(PROFILES_DIR, name);
355
440
  await ensureDir(profileDir);
356
- await writeJson(join2(profileDir, "profile.json"), data);
441
+ await writeJson(join3(profileDir, "profile.json"), data);
357
442
  }
358
443
  /**
359
444
  * Returns true if a profile directory exists.
360
445
  */
361
446
  static async profileExists(name) {
362
- return fileExists(join2(PROFILES_DIR, name, "profile.json"));
447
+ return fileExists(join3(PROFILES_DIR, name, "profile.json"));
363
448
  }
364
449
  /**
365
450
  * Returns an array of profile directory names.
@@ -379,7 +464,7 @@ var ProfileManager = class _ProfileManager {
379
464
  `Cannot delete the default profile "${name}". Change the default first with \`tc profile default <other>\`.`
380
465
  );
381
466
  }
382
- const profileDir = join2(PROFILES_DIR, name);
467
+ const profileDir = join3(PROFILES_DIR, name);
383
468
  await removeDir(profileDir);
384
469
  }
385
470
  // ── Key management ──────────────────────────────────────────────────
@@ -387,36 +472,36 @@ var ProfileManager = class _ProfileManager {
387
472
  * Returns the parsed JWK for a profile, or null if no key exists.
388
473
  */
389
474
  static async getKey(name) {
390
- return readJson(join2(PROFILES_DIR, name, "key.json"));
475
+ return readJson(join3(PROFILES_DIR, name, "key.json"));
391
476
  }
392
477
  /**
393
478
  * Saves a JWK key for a profile.
394
479
  */
395
480
  static async setKey(name, jwk) {
396
- const profileDir = join2(PROFILES_DIR, name);
481
+ const profileDir = join3(PROFILES_DIR, name);
397
482
  await ensureDir(profileDir);
398
- await writeJson(join2(profileDir, "key.json"), jwk);
483
+ await writeJson(join3(profileDir, "key.json"), jwk);
399
484
  }
400
485
  // ── Session management ──────────────────────────────────────────────
401
486
  /**
402
487
  * Returns the parsed session for a profile, or null if none exists.
403
488
  */
404
489
  static async getSession(name) {
405
- return readJson(join2(PROFILES_DIR, name, "session.json"));
490
+ return readJson(join3(PROFILES_DIR, name, "session.json"));
406
491
  }
407
492
  /**
408
493
  * Saves session data for a profile.
409
494
  */
410
495
  static async setSession(name, session) {
411
- const profileDir = join2(PROFILES_DIR, name);
496
+ const profileDir = join3(PROFILES_DIR, name);
412
497
  await ensureDir(profileDir);
413
- await writeJson(join2(profileDir, "session.json"), session);
498
+ await writeJson(join3(profileDir, "session.json"), session);
414
499
  }
415
500
  /**
416
501
  * Removes the session file for a profile.
417
502
  */
418
503
  static async clearSession(name) {
419
- const sessionPath = join2(PROFILES_DIR, name, "session.json");
504
+ const sessionPath = join3(PROFILES_DIR, name, "session.json");
420
505
  try {
421
506
  await rm2(sessionPath);
422
507
  } catch (err) {
@@ -430,7 +515,7 @@ var ProfileManager = class _ProfileManager {
430
515
  * Returns the path to the profile's cache directory, creating it if needed.
431
516
  */
432
517
  static async getCacheDir(name) {
433
- const cacheDir = join2(PROFILES_DIR, name, "cache");
518
+ const cacheDir = join3(PROFILES_DIR, name, "cache");
434
519
  await ensureDir(cacheDir);
435
520
  return cacheDir;
436
521
  }
@@ -451,6 +536,7 @@ var ProfileManager = class _ProfileManager {
451
536
  } catch {
452
537
  }
453
538
  const host = options.host ?? process.env.TC_HOST ?? profileHost ?? DEFAULT_HOST;
539
+ setActiveProfileName(profile);
454
540
  return {
455
541
  profile,
456
542
  host,
@@ -518,7 +604,6 @@ async function localKeySignIn(options) {
518
604
  // src/auth/browser-auth.ts
519
605
  import { createServer } from "http";
520
606
  import { createInterface } from "readline";
521
- var OPENKEY_BASE = "https://openkey.so";
522
607
  async function startAuthFlow(did, options = {}) {
523
608
  if (options.paste) {
524
609
  return pasteFlow(did, options);
@@ -546,7 +631,17 @@ function buildAuthUrl(did, options = {}) {
546
631
  if (options.host) {
547
632
  params.set("host", options.host);
548
633
  }
549
- return `${OPENKEY_BASE}/delegate?${params.toString()}`;
634
+ if (options.permissions?.length) {
635
+ params.set(
636
+ "permissions",
637
+ Buffer.from(JSON.stringify({ permissions: options.permissions })).toString("base64url")
638
+ );
639
+ }
640
+ if (options.expiry !== void 0) {
641
+ params.set("expiry", String(options.expiry));
642
+ }
643
+ const base = options.openkeyHost ?? DEFAULT_OPENKEY_HOST;
644
+ return `${base}/delegate?${params.toString()}`;
550
645
  }
551
646
  async function callbackFlow(did, options = {}) {
552
647
  return new Promise((resolve3, reject) => {
@@ -749,6 +844,334 @@ function registerInitCommand(program2) {
749
844
 
750
845
  // src/commands/auth.ts
751
846
  import { createInterface as createInterface2 } from "readline";
847
+
848
+ // src/lib/sdk.ts
849
+ import { TinyCloudNode } from "@tinycloud/node-sdk";
850
+
851
+ // src/lib/permissions.ts
852
+ import { appendFile, readFile as readFile2 } from "fs/promises";
853
+ import { join as join4 } from "path";
854
+ import {
855
+ expandActionShortNames,
856
+ isCapabilitySubset,
857
+ resolveManifest
858
+ } from "@tinycloud/node-sdk";
859
+
860
+ // src/lib/space.ts
861
+ function resolveAddress(profile, session) {
862
+ const sessAddr = session?.address;
863
+ if (typeof sessAddr === "string" && sessAddr.length > 0) return sessAddr;
864
+ if (profile.address) return profile.address;
865
+ if (profile.primaryDid) {
866
+ const match = profile.primaryDid.match(/^did:pkh:eip155:\d+:(0x[a-fA-F0-9]{40})$/);
867
+ if (match) return match[1];
868
+ }
869
+ throw new CLIError(
870
+ "ADDRESS_UNKNOWN",
871
+ `Cannot determine Ethereum address for profile "${profile.name}". Run \`tc auth login\` to refresh the session.`,
872
+ ExitCode.AUTH_REQUIRED
873
+ );
874
+ }
875
+ function resolveChainId(profile, session) {
876
+ const sessChain = session?.chainId;
877
+ if (typeof sessChain === "number" && Number.isFinite(sessChain)) return sessChain;
878
+ return profile.chainId;
879
+ }
880
+ async function resolveSpaceUri(input, profileName) {
881
+ if (!input) return void 0;
882
+ if (input.startsWith("tinycloud:")) return input;
883
+ if (!/^[A-Za-z0-9_-]+$/.test(input)) {
884
+ throw new CLIError(
885
+ "INVALID_SPACE",
886
+ `Invalid --space "${input}". Use a short name ([A-Za-z0-9_-]) or a full tinycloud:... URI.`,
887
+ ExitCode.USAGE_ERROR
888
+ );
889
+ }
890
+ const profile = await ProfileManager.getProfile(profileName);
891
+ const session = await ProfileManager.getSession(profileName);
892
+ const address = resolveAddress(profile, session);
893
+ const chainId = resolveChainId(profile, session);
894
+ return `tinycloud:pkh:eip155:${chainId}:${address}:${input}`;
895
+ }
896
+
897
+ // src/lib/permissions.ts
898
+ function additionalDelegationsPath(profile) {
899
+ return join4(PROFILES_DIR, profile, "additional-delegations.json");
900
+ }
901
+ function grantHistoryPath(profile) {
902
+ return join4(PROFILES_DIR, profile, "auth-grants.jsonl");
903
+ }
904
+ async function loadAdditionalDelegations(profile) {
905
+ const raw = await readJson(
906
+ additionalDelegationsPath(profile)
907
+ );
908
+ return Array.isArray(raw) ? raw : [];
909
+ }
910
+ async function saveAdditionalDelegations(profile, entries) {
911
+ const profileDir = join4(PROFILES_DIR, profile);
912
+ await ensureDir(profileDir);
913
+ await writeJson(additionalDelegationsPath(profile), entries);
914
+ }
915
+ async function appendAdditionalDelegation(profile, entry) {
916
+ const existing = await loadAdditionalDelegations(profile);
917
+ const next = existing.filter((item) => item.delegation.cid !== entry.delegation.cid);
918
+ next.push(entry);
919
+ await saveAdditionalDelegations(profile, next);
920
+ }
921
+ async function replayAdditionalDelegations(node, profile) {
922
+ const entries = await loadAdditionalDelegations(profile);
923
+ for (const entry of entries) {
924
+ const expiry = entry.delegation.expiry instanceof Date ? entry.delegation.expiry : new Date(entry.delegation.expiry);
925
+ if (expiry.getTime() <= Date.now()) continue;
926
+ try {
927
+ await node.useRuntimeDelegation({ ...entry.delegation, expiry });
928
+ } catch {
929
+ }
930
+ }
931
+ }
932
+ function storedAdditionalDelegation(delegation, permissions) {
933
+ return { delegation, permissions };
934
+ }
935
+ async function appendGrantHistory(profile, entry) {
936
+ const profileDir = join4(PROFILES_DIR, profile);
937
+ await ensureDir(profileDir);
938
+ const line = JSON.stringify({
939
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
940
+ profile,
941
+ ...entry
942
+ }) + "\n";
943
+ await appendFile(grantHistoryPath(profile), line, "utf8");
944
+ }
945
+ async function readGrantHistory(profile) {
946
+ const path = grantHistoryPath(profile);
947
+ if (!await fileExists(path)) return [];
948
+ const raw = await readFile2(path, "utf8");
949
+ return raw.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
950
+ }
951
+ async function parseCapSpec(spec, profile) {
952
+ const firstColon = spec.indexOf(":");
953
+ const lastColon = spec.lastIndexOf(":");
954
+ if (firstColon <= 0 || lastColon <= firstColon) {
955
+ throw new CLIError(
956
+ "INVALID_CAP",
957
+ `Invalid --cap "${spec}". Expected tinycloud.<service>:<space>:<path>:<actions-csv>.`,
958
+ ExitCode.USAGE_ERROR
959
+ );
960
+ }
961
+ const service = normalizeService(spec.slice(0, firstColon));
962
+ const actionsCsv = spec.slice(lastColon + 1);
963
+ const spaceAndPath = spec.slice(firstColon + 1, lastColon);
964
+ const { space, path } = splitSpaceAndPath(spaceAndPath);
965
+ const resolvedSpace = await resolveSpaceUri(space, profile) ?? space;
966
+ const actions = expandActionShortNames(
967
+ service,
968
+ actionsCsv.split(",").map((action) => action.trim()).filter(Boolean)
969
+ );
970
+ if (actions.length === 0) {
971
+ throw new CLIError("INVALID_CAP", `Capability "${spec}" has no actions.`, ExitCode.USAGE_ERROR);
972
+ }
973
+ return { service, space: resolvedSpace, path, actions };
974
+ }
975
+ async function loadPermissionRequest(source, profile) {
976
+ const raw = JSON.parse(await readFile2(source, "utf8"));
977
+ if (!Array.isArray(raw.permissions)) {
978
+ throw new CLIError(
979
+ "INVALID_PERMISSION_REQUEST",
980
+ `Permission request ${source} must contain { "permissions": [...] }.`,
981
+ ExitCode.USAGE_ERROR
982
+ );
983
+ }
984
+ return resolvePermissionSpaces(raw.permissions, profile);
985
+ }
986
+ async function loadManifestPermissions(source, profile) {
987
+ const raw = await loadManifestText(source);
988
+ const manifest = JSON.parse(raw);
989
+ if (typeof manifest.id === "string") {
990
+ const resolved = resolveManifest(manifest);
991
+ return resolvePermissionSpaces(resolved.resources, profile);
992
+ }
993
+ if (typeof manifest.app_id === "string") {
994
+ const permissions = (manifest.permissions ?? []).filter((entry) => entry !== null && typeof entry === "object").map((entry) => {
995
+ const service = normalizeService(String(entry.service ?? ""));
996
+ const path = String(entry.path ?? "");
997
+ const skipPrefix = entry.skipPrefix === true;
998
+ const resolvedPath = skipPrefix ? path : prefixAppManifestPath(path, manifest.app_id);
999
+ return {
1000
+ service,
1001
+ space: String(manifest.space ?? "applications"),
1002
+ path: resolvedPath,
1003
+ actions: expandActionShortNames(
1004
+ service,
1005
+ Array.isArray(entry.actions) ? entry.actions.map(String) : []
1006
+ )
1007
+ };
1008
+ });
1009
+ return resolvePermissionSpaces(permissions, profile);
1010
+ }
1011
+ throw new CLIError(
1012
+ "INVALID_MANIFEST",
1013
+ 'Manifest must contain either SDK field "id" or app manifest field "app_id".',
1014
+ ExitCode.USAGE_ERROR
1015
+ );
1016
+ }
1017
+ function permissionsFromDelegation(delegation) {
1018
+ if (delegation.resources?.length) {
1019
+ return delegation.resources.map((resource) => ({
1020
+ service: resource.service.startsWith("tinycloud.") ? resource.service : `tinycloud.${resource.service}`,
1021
+ space: resource.space,
1022
+ path: resource.path,
1023
+ actions: [...resource.actions]
1024
+ }));
1025
+ }
1026
+ return [{
1027
+ service: serviceFromActions(delegation.actions),
1028
+ space: delegation.spaceId,
1029
+ path: delegation.path,
1030
+ actions: [...delegation.actions]
1031
+ }];
1032
+ }
1033
+ function compactPermission(permission) {
1034
+ const service = permission.service;
1035
+ const space = permission.space.startsWith("tinycloud:") ? permission.space.slice(permission.space.lastIndexOf(":") + 1) : permission.space;
1036
+ const actions = permission.actions.map((action) => action.startsWith(`${service}/`) ? action.slice(service.length + 1) : action).join(",");
1037
+ return `${service}:${space}:${permission.path}:${actions}`;
1038
+ }
1039
+ async function resolvePermissionSpaces(entries, profile) {
1040
+ const resolved = [];
1041
+ for (const entry of entries) {
1042
+ const service = normalizeService(entry.service);
1043
+ resolved.push({
1044
+ ...entry,
1045
+ service,
1046
+ space: await resolveSpaceUri(entry.space, profile) ?? entry.space,
1047
+ actions: expandActionShortNames(service, entry.actions)
1048
+ });
1049
+ }
1050
+ return resolved;
1051
+ }
1052
+ async function loadManifestText(source) {
1053
+ if (source.startsWith("base64:")) {
1054
+ return Buffer.from(source.slice("base64:".length), "base64").toString("utf8");
1055
+ }
1056
+ if (await fileExists(source)) {
1057
+ return readFile2(source, "utf8");
1058
+ }
1059
+ try {
1060
+ const decoded = Buffer.from(source, "base64").toString("utf8");
1061
+ JSON.parse(decoded);
1062
+ return decoded;
1063
+ } catch {
1064
+ return readFile2(source, "utf8");
1065
+ }
1066
+ }
1067
+ function normalizeService(service) {
1068
+ if (!service) {
1069
+ throw new CLIError("INVALID_CAP", "Capability service is required.", ExitCode.USAGE_ERROR);
1070
+ }
1071
+ return service.startsWith("tinycloud.") ? service : `tinycloud.${service}`;
1072
+ }
1073
+ function splitSpaceAndPath(input) {
1074
+ if (input.startsWith("tinycloud:")) {
1075
+ const parts = input.split(":");
1076
+ if (parts.length < 7) {
1077
+ throw new CLIError(
1078
+ "INVALID_CAP",
1079
+ `Full tinycloud space specs must include a path after the space URI.`,
1080
+ ExitCode.USAGE_ERROR
1081
+ );
1082
+ }
1083
+ return {
1084
+ space: parts.slice(0, 6).join(":"),
1085
+ path: parts.slice(6).join(":")
1086
+ };
1087
+ }
1088
+ const colon = input.indexOf(":");
1089
+ if (colon <= 0) {
1090
+ throw new CLIError(
1091
+ "INVALID_CAP",
1092
+ `Capability must include both space and path.`,
1093
+ ExitCode.USAGE_ERROR
1094
+ );
1095
+ }
1096
+ return {
1097
+ space: input.slice(0, colon),
1098
+ path: input.slice(colon + 1)
1099
+ };
1100
+ }
1101
+ function prefixAppManifestPath(path, appId) {
1102
+ const slash = path.indexOf("/");
1103
+ if (slash === -1) return `${appId}/${path}`;
1104
+ return `${path.slice(0, slash)}/${appId}/${path.slice(slash + 1)}`;
1105
+ }
1106
+ function serviceFromActions(actions) {
1107
+ const first = actions[0] ?? "tinycloud.unknown/read";
1108
+ return first.includes("/") ? first.slice(0, first.indexOf("/")) : "tinycloud.unknown";
1109
+ }
1110
+
1111
+ // src/lib/sdk.ts
1112
+ async function createSDKInstance(ctx, options) {
1113
+ const profile = await ProfileManager.getProfile(ctx.profile);
1114
+ const session = await ProfileManager.getSession(ctx.profile);
1115
+ const key = await ProfileManager.getKey(ctx.profile);
1116
+ const effectivePrivateKey = options?.privateKey ?? profile.privateKey;
1117
+ if (!key && !effectivePrivateKey) {
1118
+ throw new CLIError(
1119
+ "AUTH_REQUIRED",
1120
+ `No key found for profile "${ctx.profile}". Run \`tc init\` first.`,
1121
+ ExitCode.AUTH_REQUIRED
1122
+ );
1123
+ }
1124
+ if (profile.authMethod === "local" && effectivePrivateKey) {
1125
+ const node2 = new TinyCloudNode({
1126
+ host: ctx.host,
1127
+ privateKey: effectivePrivateKey
1128
+ });
1129
+ await node2.signIn();
1130
+ await replayAdditionalDelegations(node2, ctx.profile);
1131
+ return node2;
1132
+ }
1133
+ const node = new TinyCloudNode({
1134
+ host: ctx.host,
1135
+ privateKey: options?.privateKey
1136
+ });
1137
+ if (options?.privateKey) {
1138
+ await node.signIn();
1139
+ } else if (session && session.delegationHeader && session.delegationCid && session.spaceId) {
1140
+ await node.restoreSession({
1141
+ delegationHeader: session.delegationHeader,
1142
+ delegationCid: session.delegationCid,
1143
+ spaceId: session.spaceId,
1144
+ jwk: session.jwk ?? key,
1145
+ verificationMethod: session.verificationMethod ?? profile.did,
1146
+ address: session.address,
1147
+ chainId: session.chainId,
1148
+ siwe: session.siwe,
1149
+ signature: session.signature
1150
+ });
1151
+ }
1152
+ await replayAdditionalDelegations(node, ctx.profile);
1153
+ return node;
1154
+ }
1155
+ async function ensureAuthenticated(ctx, options) {
1156
+ const profile = await ProfileManager.getProfile(ctx.profile).catch(() => null);
1157
+ if (profile?.authMethod === "local" && profile.privateKey) {
1158
+ return createSDKInstance(ctx, { privateKey: profile.privateKey });
1159
+ }
1160
+ const session = await ProfileManager.getSession(ctx.profile);
1161
+ if (!session) {
1162
+ throw new CLIError(
1163
+ "AUTH_REQUIRED",
1164
+ `Not authenticated. Run \`tc auth login\` or \`tc init\` first.`,
1165
+ ExitCode.AUTH_REQUIRED
1166
+ );
1167
+ }
1168
+ return createSDKInstance(ctx, options);
1169
+ }
1170
+
1171
+ // src/commands/auth.ts
1172
+ function resolveOpenKeyHost(profile) {
1173
+ return process.env.TC_OPENKEY_HOST ?? profile.openkeyHost ?? DEFAULT_OPENKEY_HOST;
1174
+ }
752
1175
  async function promptAuthMethod() {
753
1176
  if (!isInteractive()) {
754
1177
  return "local";
@@ -854,6 +1277,177 @@ function registerAuthCommand(program2) {
854
1277
  handleError(error);
855
1278
  }
856
1279
  });
1280
+ auth.command("request").description("Request additional TinyCloud permissions for the active session").option(
1281
+ "--cap <spec>",
1282
+ "Capability spec: tinycloud.<service>:<space>:<path>:<actions-csv> (repeatable)",
1283
+ (value, previous) => [...previous, value],
1284
+ []
1285
+ ).option("--permission <file>", 'JSON permission request: { "permissions": PermissionEntry[] }').option("--manifest <fileOrBase64>", "Manifest file, base64:<json>, or raw base64 JSON").option(
1286
+ "--expiry <duration>",
1287
+ `Lifetime of the granted delegation. ms-format string (e.g. "7d", "30m") or raw milliseconds. Defaults to 7d, capped by the active session's expiry.`
1288
+ ).option("--yes", "Skip local-key TTY confirmation", false).action(async (options, cmd) => {
1289
+ try {
1290
+ const globalOpts = cmd.optsWithGlobals();
1291
+ const ctx = await ProfileManager.resolveContext(globalOpts);
1292
+ const profile = await ProfileManager.getProfile(ctx.profile);
1293
+ const session = await ProfileManager.getSession(ctx.profile);
1294
+ const requested = await collectRequestedPermissions(options, ctx.profile);
1295
+ if (requested.length === 0) {
1296
+ throw new CLIError(
1297
+ "NO_CAPS_REQUESTED",
1298
+ "Provide at least one --cap, --permission, or --manifest.",
1299
+ ExitCode.USAGE_ERROR
1300
+ );
1301
+ }
1302
+ const node = await ensureAuthenticated(ctx);
1303
+ if (node.hasRuntimePermissions(requested)) {
1304
+ outputJson({ changed: false, missing: [], added: [] });
1305
+ return;
1306
+ }
1307
+ if (profile.authMethod === "openkey") {
1308
+ const key = await ProfileManager.getKey(ctx.profile);
1309
+ if (!key) {
1310
+ throw new CLIError("NO_KEY", `No key found for profile "${ctx.profile}". Run \`tc init\` first.`, ExitCode.AUTH_REQUIRED);
1311
+ }
1312
+ const delegationCids2 = [];
1313
+ let expiry2;
1314
+ const openkeyHost = resolveOpenKeyHost(profile);
1315
+ const expiryOption2 = parseExpiryOption(options.expiry);
1316
+ for (const group of groupPermissionsBySpace(requested)) {
1317
+ const delegationData = await startAuthFlow(profile.did, {
1318
+ jwk: key,
1319
+ host: ctx.host,
1320
+ permissions: group,
1321
+ openkeyHost,
1322
+ expiry: expiryOption2
1323
+ });
1324
+ const delegation = portableFromOpenKeyDelegation(delegationData, group, ctx.host);
1325
+ const stored = storedAdditionalDelegation(delegation, group);
1326
+ await appendAdditionalDelegation(ctx.profile, stored);
1327
+ await node.useRuntimeDelegation(delegation);
1328
+ delegationCids2.push(delegation.cid);
1329
+ expiry2 = delegation.expiry.toISOString();
1330
+ await appendGrantHistory(ctx.profile, {
1331
+ addedCaps: group,
1332
+ source: options.manifest ? "manifest" : "cli",
1333
+ delegationCid: delegation.cid,
1334
+ expiry: expiry2
1335
+ });
1336
+ }
1337
+ outputJson({
1338
+ changed: delegationCids2.length > 0,
1339
+ added: requested,
1340
+ delegationCid: delegationCids2[0],
1341
+ delegationCids: delegationCids2,
1342
+ expiry: expiry2
1343
+ });
1344
+ return;
1345
+ }
1346
+ if (isInteractive()) {
1347
+ if (!options.yes) {
1348
+ await confirmPermissionRequest(requested);
1349
+ }
1350
+ } else if (!options.yes) {
1351
+ throw new CLIError(
1352
+ "CONFIRMATION_REQUIRED",
1353
+ "Local-key permission requests in non-interactive mode require --yes.",
1354
+ ExitCode.USAGE_ERROR
1355
+ );
1356
+ }
1357
+ void session;
1358
+ const expiryOption = parseExpiryOption(options.expiry);
1359
+ const delegations = await node.grantRuntimePermissions(
1360
+ requested,
1361
+ expiryOption !== void 0 ? { expiry: expiryOption } : void 0
1362
+ );
1363
+ const delegationCids = [];
1364
+ let expiry;
1365
+ for (const delegation of delegations) {
1366
+ const covering = permissionsFromDelegation(delegation);
1367
+ const stored = storedAdditionalDelegation(delegation, covering);
1368
+ await appendAdditionalDelegation(ctx.profile, stored);
1369
+ delegationCids.push(delegation.cid);
1370
+ expiry = delegation.expiry.toISOString();
1371
+ await appendGrantHistory(ctx.profile, {
1372
+ addedCaps: covering,
1373
+ source: options.manifest ? "manifest" : "cli",
1374
+ delegationCid: delegation.cid,
1375
+ expiry
1376
+ });
1377
+ }
1378
+ if (delegationCids.length === 0) {
1379
+ outputJson({ changed: false, missing: [], added: [] });
1380
+ return;
1381
+ }
1382
+ outputJson({
1383
+ changed: true,
1384
+ added: requested,
1385
+ delegationCid: delegationCids[0],
1386
+ delegationCids,
1387
+ expiry
1388
+ });
1389
+ } catch (error) {
1390
+ handleError(error);
1391
+ }
1392
+ });
1393
+ auth.command("caps").description("Show granted capabilities for the active session").option("--diff <spec>", "Show missing capabilities for a spec").option("--history", "Show recent permission grants").action(async (options, cmd) => {
1394
+ try {
1395
+ const globalOpts = cmd.optsWithGlobals();
1396
+ const ctx = await ProfileManager.resolveContext(globalOpts);
1397
+ if (options.history) {
1398
+ const history = (await readGrantHistory(ctx.profile)).slice(-20);
1399
+ if (shouldOutputJson()) {
1400
+ outputJson({ grants: history });
1401
+ } else if (history.length === 0) {
1402
+ process.stdout.write(theme.muted("No grant history.") + "\n");
1403
+ } else {
1404
+ process.stdout.write(formatTable(
1405
+ ["time", "source", "delegation", "caps"],
1406
+ history.map((entry) => [
1407
+ entry.ts,
1408
+ entry.source,
1409
+ entry.delegationCid ?? "",
1410
+ entry.addedCaps.map(compactPermission).join("; ")
1411
+ ])
1412
+ ) + "\n");
1413
+ }
1414
+ return;
1415
+ }
1416
+ const node = await ensureAuthenticated(ctx);
1417
+ const runtimeDelegations = node.getRuntimePermissionDelegations();
1418
+ const granted = runtimeDelegations.flatMap(permissionsFromDelegation);
1419
+ if (options.diff) {
1420
+ const requested = [await parseCapSpec(options.diff, ctx.profile)];
1421
+ const covered = node.hasRuntimePermissions(requested);
1422
+ outputJson({
1423
+ requested,
1424
+ changed: !covered,
1425
+ covered,
1426
+ // `missing` retained for backwards-compatible callers.
1427
+ missing: covered ? [] : requested
1428
+ });
1429
+ return;
1430
+ }
1431
+ const appended = await loadAdditionalDelegations(ctx.profile);
1432
+ if (shouldOutputJson()) {
1433
+ outputJson({ granted, appendedDelegations: appended.length });
1434
+ } else if (granted.length === 0) {
1435
+ process.stdout.write(theme.muted("No appended runtime delegations on this profile.") + "\n");
1436
+ } else {
1437
+ process.stdout.write(formatTable(
1438
+ ["service", "space", "path", "actions"],
1439
+ granted.map((entry) => [
1440
+ entry.service,
1441
+ entry.space,
1442
+ entry.path,
1443
+ entry.actions.join(", ")
1444
+ ])
1445
+ ) + "\n");
1446
+ }
1447
+ } catch (error) {
1448
+ handleError(error);
1449
+ }
1450
+ });
857
1451
  auth.command("whoami").description("Show identity information").action(async (_options, cmd) => {
858
1452
  try {
859
1453
  const globalOpts = cmd.optsWithGlobals();
@@ -888,6 +1482,111 @@ function registerAuthCommand(program2) {
888
1482
  }
889
1483
  });
890
1484
  }
1485
+ async function collectRequestedPermissions(options, profile) {
1486
+ const permissions = [];
1487
+ for (const spec of options.cap ?? []) {
1488
+ permissions.push(await parseCapSpec(spec, profile));
1489
+ }
1490
+ if (options.permission) {
1491
+ permissions.push(...await loadPermissionRequest(options.permission, profile));
1492
+ }
1493
+ if (options.manifest) {
1494
+ permissions.push(...await loadManifestPermissions(options.manifest, profile));
1495
+ }
1496
+ return permissions;
1497
+ }
1498
+ async function confirmPermissionRequest(permissions) {
1499
+ process.stderr.write("\n" + theme.heading("Additional Permissions") + "\n");
1500
+ for (const permission of permissions) {
1501
+ const dangerous = isDangerousPermission(permission);
1502
+ const line = ` ${compactPermission(permission)}`;
1503
+ process.stderr.write((dangerous ? theme.warn(line) : theme.value(line)) + "\n");
1504
+ }
1505
+ process.stderr.write("\n");
1506
+ const rl = createInterface2({
1507
+ input: process.stdin,
1508
+ output: process.stderr
1509
+ });
1510
+ const answer = await new Promise((resolve3) => {
1511
+ rl.question("Approve local-key delegation? [y/N] ", resolve3);
1512
+ });
1513
+ rl.close();
1514
+ if (!/^y(es)?$/i.test(answer.trim())) {
1515
+ throw new CLIError("REQUEST_CANCELLED", "Permission request cancelled.", ExitCode.ERROR);
1516
+ }
1517
+ }
1518
+ function isDangerousPermission(permission) {
1519
+ if (permission.path === "" || permission.path === "/") return true;
1520
+ return permission.actions.some(
1521
+ (action) => action.includes("*") || action.endsWith("/write") || action.endsWith("/admin") || action.endsWith("/ddl") || action.endsWith("/del")
1522
+ );
1523
+ }
1524
+ function parseExpiryOption(raw) {
1525
+ if (raw === void 0 || raw === null) return void 0;
1526
+ if (typeof raw !== "string" || raw.length === 0) {
1527
+ throw new CLIError(
1528
+ "INVALID_EXPIRY",
1529
+ `--expiry must be a string (e.g. "7d", "30m") or a millisecond integer.`,
1530
+ ExitCode.USAGE_ERROR
1531
+ );
1532
+ }
1533
+ if (/^\d+$/.test(raw.trim())) {
1534
+ const ms = Number(raw.trim());
1535
+ if (!Number.isFinite(ms) || ms <= 0) {
1536
+ throw new CLIError("INVALID_EXPIRY", `--expiry must be a positive integer when numeric.`, ExitCode.USAGE_ERROR);
1537
+ }
1538
+ return ms;
1539
+ }
1540
+ return raw;
1541
+ }
1542
+ function groupPermissionsBySpace(permissions) {
1543
+ const groups = /* @__PURE__ */ new Map();
1544
+ for (const permission of permissions) {
1545
+ const group = groups.get(permission.space) ?? [];
1546
+ group.push(permission);
1547
+ groups.set(permission.space, group);
1548
+ }
1549
+ return Array.from(groups.values());
1550
+ }
1551
+ function portableFromOpenKeyDelegation(data, permissions, host) {
1552
+ const primary = permissions[0];
1553
+ const returnedSpace = String(data.spaceId ?? primary.space);
1554
+ const expectedSpaces = new Set(permissions.map((permission) => permission.space));
1555
+ if (expectedSpaces.size !== 1 || !expectedSpaces.has(returnedSpace)) {
1556
+ throw new CLIError(
1557
+ "OPENKEY_SCOPE_MISMATCH",
1558
+ `OpenKey returned delegation for ${returnedSpace}, expected ${Array.from(expectedSpaces).join(", ")}.`,
1559
+ ExitCode.PERMISSION_DENIED
1560
+ );
1561
+ }
1562
+ const expiry = inferDelegationExpiry(data);
1563
+ return {
1564
+ cid: String(data.delegationCid),
1565
+ delegationHeader: data.delegationHeader,
1566
+ spaceId: returnedSpace,
1567
+ path: primary.path,
1568
+ actions: primary.actions,
1569
+ resources: permissions.map((permission) => ({
1570
+ service: permission.service.startsWith("tinycloud.") ? permission.service.slice("tinycloud.".length) : permission.service,
1571
+ space: permission.space,
1572
+ path: permission.path,
1573
+ actions: [...permission.actions]
1574
+ })),
1575
+ expiry,
1576
+ delegateDID: String(data.verificationMethod),
1577
+ ownerAddress: String(data.address ?? ""),
1578
+ chainId: typeof data.chainId === "number" ? data.chainId : DEFAULT_CHAIN_ID,
1579
+ host
1580
+ };
1581
+ }
1582
+ function inferDelegationExpiry(data) {
1583
+ const direct = data.expiry ?? data.expiresAt;
1584
+ if (typeof direct === "string") {
1585
+ const parsed = new Date(direct);
1586
+ if (!Number.isNaN(parsed.getTime())) return parsed;
1587
+ }
1588
+ return new Date(Date.now() + 60 * 60 * 1e3);
1589
+ }
891
1590
  async function handleLocalAuth(profileName, host) {
892
1591
  const profile = await ProfileManager.getProfile(profileName).catch(() => null);
893
1592
  let privateKey;
@@ -965,7 +1664,8 @@ async function handleOpenKeyAuth(profileName, host, paste) {
965
1664
  const delegationData = await startAuthFlow(profile.did, {
966
1665
  paste,
967
1666
  jwk: key,
968
- host
1667
+ host,
1668
+ openkeyHost: resolveOpenKeyHost(profile)
969
1669
  });
970
1670
  await ProfileManager.setSession(profileName, delegationData);
971
1671
  const updatedProfile = {
@@ -987,67 +1687,8 @@ async function handleOpenKeyAuth(profileName, host, paste) {
987
1687
  }
988
1688
 
989
1689
  // src/commands/kv.ts
990
- import { readFile as readFile2 } from "fs/promises";
1690
+ import { readFile as readFile3 } from "fs/promises";
991
1691
  import { writeFile as writeFile2 } from "fs/promises";
992
-
993
- // src/lib/sdk.ts
994
- import { TinyCloudNode } from "@tinycloud/node-sdk";
995
- async function createSDKInstance(ctx, options) {
996
- const profile = await ProfileManager.getProfile(ctx.profile);
997
- const session = await ProfileManager.getSession(ctx.profile);
998
- const key = await ProfileManager.getKey(ctx.profile);
999
- const effectivePrivateKey = options?.privateKey ?? profile.privateKey;
1000
- if (!key && !effectivePrivateKey) {
1001
- throw new CLIError(
1002
- "AUTH_REQUIRED",
1003
- `No key found for profile "${ctx.profile}". Run \`tc init\` first.`,
1004
- ExitCode.AUTH_REQUIRED
1005
- );
1006
- }
1007
- if (profile.authMethod === "local" && effectivePrivateKey) {
1008
- const node2 = new TinyCloudNode({
1009
- host: ctx.host,
1010
- privateKey: effectivePrivateKey
1011
- });
1012
- await node2.signIn();
1013
- return node2;
1014
- }
1015
- const node = new TinyCloudNode({
1016
- host: ctx.host,
1017
- privateKey: options?.privateKey
1018
- });
1019
- if (options?.privateKey) {
1020
- await node.signIn();
1021
- } else if (session && session.delegationHeader && session.delegationCid && session.spaceId) {
1022
- await node.restoreSession({
1023
- delegationHeader: session.delegationHeader,
1024
- delegationCid: session.delegationCid,
1025
- spaceId: session.spaceId,
1026
- jwk: session.jwk ?? key,
1027
- verificationMethod: session.verificationMethod ?? profile.did,
1028
- address: session.address,
1029
- chainId: session.chainId
1030
- });
1031
- }
1032
- return node;
1033
- }
1034
- async function ensureAuthenticated(ctx, options) {
1035
- const profile = await ProfileManager.getProfile(ctx.profile).catch(() => null);
1036
- if (profile?.authMethod === "local" && profile.privateKey) {
1037
- return createSDKInstance(ctx, { privateKey: profile.privateKey });
1038
- }
1039
- const session = await ProfileManager.getSession(ctx.profile);
1040
- if (!session) {
1041
- throw new CLIError(
1042
- "AUTH_REQUIRED",
1043
- `Not authenticated. Run \`tc auth login\` or \`tc init\` first.`,
1044
- ExitCode.AUTH_REQUIRED
1045
- );
1046
- }
1047
- return createSDKInstance(ctx, options);
1048
- }
1049
-
1050
- // src/commands/kv.ts
1051
1692
  async function readStdin() {
1052
1693
  const chunks = [];
1053
1694
  for await (const chunk of process.stdin) {
@@ -1110,7 +1751,7 @@ function registerKvCommand(program2) {
1110
1751
  throw new CLIError("USAGE_ERROR", "Provide only one of: value argument, --file, or --stdin", ExitCode.USAGE_ERROR);
1111
1752
  }
1112
1753
  if (options.file) {
1113
- putValue = await readFile2(options.file);
1754
+ putValue = await readFile3(options.file);
1114
1755
  } else if (options.stdin) {
1115
1756
  putValue = await readStdin();
1116
1757
  } else {
@@ -1823,7 +2464,7 @@ complete -c tc -l quiet -s q -d "Suppress non-essential output"
1823
2464
  }
1824
2465
 
1825
2466
  // src/commands/vault.ts
1826
- import { readFile as readFile3 } from "fs/promises";
2467
+ import { readFile as readFile4 } from "fs/promises";
1827
2468
  import { writeFile as writeFile3 } from "fs/promises";
1828
2469
  import { PrivateKeySigner as PrivateKeySigner2 } from "@tinycloud/node-sdk";
1829
2470
  async function readStdin2() {
@@ -1881,7 +2522,7 @@ function registerVaultCommand(program2) {
1881
2522
  throw new CLIError("USAGE_ERROR", "Provide only one of: value argument, --file, or --stdin", ExitCode.USAGE_ERROR);
1882
2523
  }
1883
2524
  if (options.file) {
1884
- putValue = new Uint8Array(await readFile3(options.file));
2525
+ putValue = new Uint8Array(await readFile4(options.file));
1885
2526
  } else if (options.stdin) {
1886
2527
  putValue = new Uint8Array(await readStdin2());
1887
2528
  } else {
@@ -1996,7 +2637,7 @@ function registerVaultCommand(program2) {
1996
2637
  }
1997
2638
 
1998
2639
  // src/commands/secrets.ts
1999
- import { readFile as readFile4 } from "fs/promises";
2640
+ import { readFile as readFile5 } from "fs/promises";
2000
2641
  import { writeFile as writeFile4 } from "fs/promises";
2001
2642
  import { PrivateKeySigner as PrivateKeySigner3 } from "@tinycloud/node-sdk";
2002
2643
  var SECRETS_PREFIX = "secrets/";
@@ -2123,7 +2764,7 @@ function registerSecretsCommand(program2) {
2123
2764
  throw new CLIError("USAGE_ERROR", "Provide only one of: value argument, --file, or --stdin", ExitCode.USAGE_ERROR);
2124
2765
  }
2125
2766
  if (options.file) {
2126
- secretValue = await readFile4(options.file, "utf-8");
2767
+ secretValue = await readFile5(options.file, "utf-8");
2127
2768
  } else if (options.stdin) {
2128
2769
  secretValue = (await readStdin3()).toString("utf-8");
2129
2770
  } else {
@@ -2172,7 +2813,7 @@ function registerSecretsCommand(program2) {
2172
2813
  }
2173
2814
 
2174
2815
  // src/commands/vars.ts
2175
- import { readFile as readFile5 } from "fs/promises";
2816
+ import { readFile as readFile6 } from "fs/promises";
2176
2817
  import { writeFile as writeFile5 } from "fs/promises";
2177
2818
  var VARIABLES_PREFIX = "variables/";
2178
2819
  async function readStdin4() {
@@ -2273,7 +2914,7 @@ function registerVarsCommand(program2) {
2273
2914
  throw new CLIError("USAGE_ERROR", "Provide only one of: value argument, --file, or --stdin", ExitCode.USAGE_ERROR);
2274
2915
  }
2275
2916
  if (options.file) {
2276
- varValue = await readFile5(options.file, "utf-8");
2917
+ varValue = await readFile6(options.file, "utf-8");
2277
2918
  } else if (options.stdin) {
2278
2919
  varValue = (await readStdin4()).toString("utf-8");
2279
2920
  } else {
@@ -2421,35 +3062,45 @@ function registerDoctorCommand(program2) {
2421
3062
  // src/commands/sql.ts
2422
3063
  import { writeFile as writeFile6 } from "fs/promises";
2423
3064
  import { resolve } from "path";
3065
+ async function dbHandle(node, dbName, spaceInput, profileName) {
3066
+ const spaceUri = await resolveSpaceUri(spaceInput, profileName);
3067
+ const sql = spaceUri ? node.sqlForSpace(spaceUri) : node.sql;
3068
+ return sql.db(dbName);
3069
+ }
2424
3070
  function registerSqlCommand(program2) {
2425
3071
  const sql = program2.command("sql").description("SQLite database operations for your TinyCloud space").addHelpText("after", `
2426
3072
 
2427
3073
  TinyCloud SQL gives each space isolated SQLite databases. Use the default
2428
- database for simple apps, or pass --db to target a named database.
3074
+ database for simple apps, or pass --db to target a named database. Pass
3075
+ --space to target a non-primary space (e.g. the manifest "applications" space).
2429
3076
 
2430
3077
  Common workflows:
2431
3078
  $ tc sql execute "CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)"
2432
3079
  $ tc sql execute "INSERT INTO notes (body) VALUES (?)" --params '["ship docs"]'
2433
3080
  $ tc sql query "SELECT id, body FROM notes ORDER BY id"
2434
3081
  $ tc sql query "SELECT * FROM events WHERE type = ?" --db analytics --params '["signup"]'
3082
+ $ tc sql query "SELECT count(*) FROM conversation" --space applications --db xyz.tinycloud.listen/conversations
2435
3083
  $ tc sql export --db analytics --output analytics.db
2436
3084
 
2437
3085
  Commands:
2438
3086
  query Read rows with SELECT statements
2439
3087
  execute Run writes and schema changes such as INSERT, UPDATE, DELETE, CREATE, DROP
2440
3088
  export Download the raw SQLite database file
3089
+ copy Copy rows between databases (optionally across spaces)
2441
3090
 
2442
3091
  Tips:
2443
3092
  - SQL strings should usually be quoted so your shell passes them as one argument.
2444
3093
  - --params accepts a JSON array and binds values to ? placeholders.
3094
+ - --space accepts a short name ("applications") or full URI ("tinycloud:pkh:eip155:1:0x...:applications").
2445
3095
  - Add --json for scripting-friendly output.
2446
3096
  `);
2447
- sql.command("query <sql>").description("Run a read-only SELECT query").option("--db <name>", "SQLite database name within the current space", "default").option("--params <json>", "Bind parameters as a JSON array for ? placeholders").addHelpText("after", `
3097
+ sql.command("query <sql>").description("Run a read-only SELECT query").option("--db <name>", "SQLite database name within the current space", "default").option("--space <name|uri>", "Target a non-primary space (short name or full URI)").option("--params <json>", "Bind parameters as a JSON array for ? placeholders").addHelpText("after", `
2448
3098
 
2449
3099
  Examples:
2450
3100
  $ tc sql query "SELECT * FROM notes ORDER BY id"
2451
3101
  $ tc sql query "SELECT * FROM notes WHERE id = ?" --params '[42]'
2452
3102
  $ tc sql query "SELECT count(*) AS total FROM events" --db analytics --json
3103
+ $ tc sql query "SELECT count(*) FROM conversation" --space applications --db xyz.tinycloud.listen/conversations
2453
3104
 
2454
3105
  Output:
2455
3106
  Human output is formatted as a table. Piped output or --json returns
@@ -2460,12 +3111,13 @@ Output:
2460
3111
  const ctx = await ProfileManager.resolveContext(globalOpts);
2461
3112
  const node = await ensureAuthenticated(ctx);
2462
3113
  const params = options.params ? JSON.parse(options.params) : void 0;
3114
+ const handle = await dbHandle(node, options.db, options.space, ctx.profile);
2463
3115
  const result = await withSpinner(
2464
3116
  "Running query...",
2465
- () => node.sql.db(options.db).query(sqlStr, params)
3117
+ () => handle.query(sqlStr, params)
2466
3118
  );
2467
3119
  if (!result.ok) {
2468
- throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR);
3120
+ throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR, result.error.meta);
2469
3121
  }
2470
3122
  const { columns, rows, rowCount } = result.data;
2471
3123
  if (shouldOutputJson()) {
@@ -2486,13 +3138,14 @@ ${rowCount} row${rowCount === 1 ? "" : "s"} returned`) + "\n");
2486
3138
  handleError(error);
2487
3139
  }
2488
3140
  });
2489
- sql.command("execute <sql>").description("Run a write or schema statement").option("--db <name>", "SQLite database name within the current space", "default").option("--params <json>", "Bind parameters as a JSON array for ? placeholders").addHelpText("after", `
3141
+ sql.command("execute <sql>").description("Run a write or schema statement").option("--db <name>", "SQLite database name within the current space", "default").option("--space <name|uri>", "Target a non-primary space (short name or full URI)").option("--params <json>", "Bind parameters as a JSON array for ? placeholders").addHelpText("after", `
2490
3142
 
2491
3143
  Examples:
2492
3144
  $ tc sql execute "CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)"
2493
3145
  $ tc sql execute "INSERT INTO notes (body) VALUES (?)" --params '["first note"]'
2494
3146
  $ tc sql execute "UPDATE notes SET body = ? WHERE id = ?" --params '["edited", 1]'
2495
3147
  $ tc sql execute "DROP TABLE old_notes" --db archive
3148
+ $ tc sql execute "DELETE FROM conversation WHERE id = ?" --space applications --db xyz.tinycloud.listen/conversations --params '["abc"]'
2496
3149
 
2497
3150
  Output:
2498
3151
  Returns JSON with the changed row count and last inserted row id when available.
@@ -2502,12 +3155,13 @@ Output:
2502
3155
  const ctx = await ProfileManager.resolveContext(globalOpts);
2503
3156
  const node = await ensureAuthenticated(ctx);
2504
3157
  const params = options.params ? JSON.parse(options.params) : void 0;
3158
+ const handle = await dbHandle(node, options.db, options.space, ctx.profile);
2505
3159
  const result = await withSpinner(
2506
3160
  "Executing statement...",
2507
- () => node.sql.db(options.db).execute(sqlStr, params)
3161
+ () => handle.execute(sqlStr, params)
2508
3162
  );
2509
3163
  if (!result.ok) {
2510
- throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR);
3164
+ throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR, result.error.meta);
2511
3165
  }
2512
3166
  outputJson({
2513
3167
  changes: result.data.changes,
@@ -2517,11 +3171,12 @@ Output:
2517
3171
  handleError(error);
2518
3172
  }
2519
3173
  });
2520
- sql.command("export").description("Export a SQLite database as a binary .db file").option("--db <name>", "SQLite database name within the current space", "default").option("-o, --output <file>", "Output file path", "export.db").addHelpText("after", `
3174
+ sql.command("export").description("Export a SQLite database as a binary .db file").option("--db <name>", "SQLite database name within the current space", "default").option("--space <name|uri>", "Target a non-primary space (short name or full URI)").option("-o, --output <file>", "Output file path", "export.db").addHelpText("after", `
2521
3175
 
2522
3176
  Examples:
2523
3177
  $ tc sql export
2524
3178
  $ tc sql export --db analytics --output analytics.db
3179
+ $ tc sql export --space applications --db xyz.tinycloud.listen/conversations --output listen.db
2525
3180
 
2526
3181
  Output:
2527
3182
  Writes the database file locally and returns JSON with the path and size.
@@ -2530,12 +3185,13 @@ Output:
2530
3185
  const globalOpts = cmd.optsWithGlobals();
2531
3186
  const ctx = await ProfileManager.resolveContext(globalOpts);
2532
3187
  const node = await ensureAuthenticated(ctx);
3188
+ const handle = await dbHandle(node, options.db, options.space, ctx.profile);
2533
3189
  const result = await withSpinner(
2534
3190
  "Exporting database...",
2535
- () => node.sql.db(options.db).export()
3191
+ () => handle.export()
2536
3192
  );
2537
3193
  if (!result.ok) {
2538
- throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR);
3194
+ throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR, result.error.meta);
2539
3195
  }
2540
3196
  const blob = result.data;
2541
3197
  const buffer = Buffer.from(await blob.arrayBuffer());
@@ -2550,10 +3206,126 @@ Output:
2550
3206
  handleError(error);
2551
3207
  }
2552
3208
  });
3209
+ sql.command("copy").description("Copy rows between SQL databases (optionally across spaces)").requiredOption("--from-db <name>", "Source database name").requiredOption("--to-db <name>", "Destination database name").option("--from-space <name|uri>", "Source space (defaults to primary)").option("--to-space <name|uri>", "Destination space (defaults to primary)").option("--table <name...>", "Restrict copy to specific tables (repeat or comma-separated)").option("--dry-run", "Print the plan without writing", false).addHelpText("after", `
3210
+
3211
+ Examples:
3212
+ $ tc sql copy --from-db com.tinycloud.conversation-sync/conversations \\
3213
+ --to-db xyz.tinycloud.listen/conversations \\
3214
+ --space applications --dry-run
3215
+ $ tc sql copy --from-space applications --from-db com.foo/data \\
3216
+ --to-space applications --to-db com.bar/data \\
3217
+ --table conversation --table participant
3218
+
3219
+ Notes:
3220
+ - Refuses to run when (resolved space, db) is identical for source and destination.
3221
+ - Does NOT create destination tables. Run the target app once (or use \`tc sql execute\`)
3222
+ to materialize the schema before copying.
3223
+ - One row at a time; suitable for small/medium datasets. Large copies should
3224
+ use \`tc sql export\` + bulk import.
3225
+ - Authorization: the active session/delegation must cover sql/read on source
3226
+ AND sql/write on destination. Otherwise the relevant operation will fail.
3227
+ `).action(async (options, cmd) => {
3228
+ try {
3229
+ const globalOpts = cmd.optsWithGlobals();
3230
+ const ctx = await ProfileManager.resolveContext(globalOpts);
3231
+ const node = await ensureAuthenticated(ctx);
3232
+ const fromSpaceInput = options.fromSpace ?? options.space;
3233
+ const toSpaceInput = options.toSpace ?? options.space;
3234
+ const fromSpaceUri = await resolveSpaceUri(fromSpaceInput, ctx.profile) ?? "<primary>";
3235
+ const toSpaceUri = await resolveSpaceUri(toSpaceInput, ctx.profile) ?? "<primary>";
3236
+ if (fromSpaceUri === toSpaceUri && options.fromDb === options.toDb) {
3237
+ throw new CLIError(
3238
+ "SELF_COPY",
3239
+ `Refusing to copy: source and destination resolve to the same (space, db) \u2014 ${fromSpaceUri} / ${options.fromDb}.`,
3240
+ ExitCode.USAGE_ERROR
3241
+ );
3242
+ }
3243
+ const fromHandle = await dbHandle(node, options.fromDb, fromSpaceInput, ctx.profile);
3244
+ const toHandle = await dbHandle(node, options.toDb, toSpaceInput, ctx.profile);
3245
+ let tables;
3246
+ if (options.table && options.table.length > 0) {
3247
+ tables = options.table.flatMap((t) => t.split(",").map((s) => s.trim()).filter(Boolean));
3248
+ } else {
3249
+ const listing = await fromHandle.query(
3250
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
3251
+ );
3252
+ if (!listing.ok) {
3253
+ throw new CLIError(listing.error.code, `Cannot list source tables: ${listing.error.message}`, ExitCode.ERROR, listing.error.meta);
3254
+ }
3255
+ tables = listing.data.rows.map((r) => String(r[0]));
3256
+ }
3257
+ if (tables.length === 0) {
3258
+ throw new CLIError(
3259
+ "EMPTY_PLAN",
3260
+ `No tables to copy. Use --table to specify tables, or check that the source database has user tables.`,
3261
+ ExitCode.USAGE_ERROR
3262
+ );
3263
+ }
3264
+ const plan = [];
3265
+ for (const table of tables) {
3266
+ const safe = quoteIdent(table);
3267
+ const countResult = await fromHandle.query(`SELECT count(*) AS n FROM ${safe}`);
3268
+ if (!countResult.ok) {
3269
+ throw new CLIError(
3270
+ countResult.error.code,
3271
+ `Cannot count rows in source table "${table}": ${countResult.error.message}`,
3272
+ ExitCode.ERROR,
3273
+ countResult.error.meta
3274
+ );
3275
+ }
3276
+ const rows = Number(countResult.data.rows[0]?.[0] ?? 0);
3277
+ plan.push({ table, rows, copied: 0, skipped: 0 });
3278
+ }
3279
+ if (options.dryRun) {
3280
+ outputJson({
3281
+ dryRun: true,
3282
+ from: { space: fromSpaceUri, db: options.fromDb },
3283
+ to: { space: toSpaceUri, db: options.toDb },
3284
+ tables: plan.map((p) => ({ table: p.table, rows: p.rows }))
3285
+ });
3286
+ return;
3287
+ }
3288
+ for (const entry of plan) {
3289
+ const safe = quoteIdent(entry.table);
3290
+ const fetched = await fromHandle.query(`SELECT * FROM ${safe}`);
3291
+ if (!fetched.ok) {
3292
+ throw new CLIError(fetched.error.code, `Failed to read "${entry.table}": ${fetched.error.message}`, ExitCode.ERROR, fetched.error.meta);
3293
+ }
3294
+ const columns = fetched.data.columns;
3295
+ const rows = fetched.data.rows;
3296
+ if (rows.length === 0) continue;
3297
+ const colList = columns.map(quoteIdent).join(", ");
3298
+ const placeholders = columns.map(() => "?").join(", ");
3299
+ const insertSql = `INSERT INTO ${safe} (${colList}) VALUES (${placeholders})`;
3300
+ for (const row of rows) {
3301
+ const writeResult = await toHandle.execute(insertSql, row);
3302
+ if (!writeResult.ok) {
3303
+ throw new CLIError(
3304
+ writeResult.error.code,
3305
+ `Insert into "${entry.table}" failed after ${entry.copied} row(s): ${writeResult.error.message}`,
3306
+ ExitCode.ERROR,
3307
+ writeResult.error.meta
3308
+ );
3309
+ }
3310
+ entry.copied += writeResult.data.changes ?? 1;
3311
+ }
3312
+ }
3313
+ outputJson({
3314
+ from: { space: fromSpaceUri, db: options.fromDb },
3315
+ to: { space: toSpaceUri, db: options.toDb },
3316
+ tables: plan.map((p) => ({ table: p.table, rowsRead: p.rows, rowsWritten: p.copied }))
3317
+ });
3318
+ } catch (error) {
3319
+ handleError(error);
3320
+ }
3321
+ });
3322
+ }
3323
+ function quoteIdent(name) {
3324
+ return `"${name.replace(/"/g, '""')}"`;
2553
3325
  }
2554
3326
 
2555
3327
  // src/commands/duckdb.ts
2556
- import { readFile as readFile6, writeFile as writeFile7 } from "fs/promises";
3328
+ import { readFile as readFile7, writeFile as writeFile7 } from "fs/promises";
2557
3329
  import { resolve as resolve2 } from "path";
2558
3330
  function registerDuckdbCommand(program2) {
2559
3331
  const duckdb = program2.command("duckdb").description("DuckDB database operations");
@@ -2683,7 +3455,7 @@ ${rowCount} row${rowCount === 1 ? "" : "s"} returned`) + "\n");
2683
3455
  const ctx = await ProfileManager.resolveContext(globalOpts);
2684
3456
  const node = await ensureAuthenticated(ctx);
2685
3457
  const filePath = resolve2(file);
2686
- const bytes = new Uint8Array(await readFile6(filePath));
3458
+ const bytes = new Uint8Array(await readFile7(filePath));
2687
3459
  const result = await withSpinner(
2688
3460
  "Importing database...",
2689
3461
  () => node.duckdb.db(options.db).import(bytes)
@@ -2703,13 +3475,142 @@ ${rowCount} row${rowCount === 1 ? "" : "s"} returned`) + "\n");
2703
3475
  });
2704
3476
  }
2705
3477
 
3478
+ // src/commands/manifest.ts
3479
+ import { readFile as readFile8 } from "fs/promises";
3480
+ var DEFAULT_APP_SPACE = "applications";
3481
+ function registerManifestCommand(program2) {
3482
+ const manifest = program2.command("manifest").description("Inspect TinyCloud app manifests");
3483
+ manifest.command("resolve <source>").description("Resolve a manifest file or URL to its effective space, paths, and DB basenames").addHelpText("after", `
3484
+
3485
+ Examples:
3486
+ $ tc manifest resolve ./manifest.json
3487
+ $ tc manifest resolve https://app.example.com/manifest.json --json
3488
+
3489
+ What it shows:
3490
+ - app_id, name, manifest_version
3491
+ - effective space name (default: "applications") and full space URI for the active profile
3492
+ - per-permission: service, fully-qualified path, actions
3493
+ - inferred SQL database basenames for sql/<db>/... paths
3494
+
3495
+ This command is read-only and does NOT contact the node \u2014 it just resolves
3496
+ the manifest against the active profile's address/chain so you know which
3497
+ \`--space\` and \`--db\` values to pass to other tc commands.
3498
+ `).action(async (source, _options, cmd) => {
3499
+ try {
3500
+ const globalOpts = cmd.optsWithGlobals();
3501
+ const ctx = await ProfileManager.resolveContext(globalOpts);
3502
+ const raw = await loadManifestSource(source);
3503
+ const parsed = JSON.parse(raw);
3504
+ if (!parsed.app_id) {
3505
+ throw new CLIError(
3506
+ "INVALID_MANIFEST",
3507
+ `Manifest is missing required field "app_id".`,
3508
+ ExitCode.ERROR
3509
+ );
3510
+ }
3511
+ await ensureAuthenticated(ctx);
3512
+ const spaceName = parsed.space ?? DEFAULT_APP_SPACE;
3513
+ const spaceUri = await resolveSpaceUri(spaceName, ctx.profile);
3514
+ const permissions = (parsed.permissions ?? []).map((p) => {
3515
+ const resolvedPath = p.skipPrefix ? p.path : prefixWithAppId(p.path, parsed.app_id);
3516
+ return {
3517
+ service: p.service,
3518
+ path: resolvedPath,
3519
+ actions: p.actions,
3520
+ sqlDb: extractSqlDbName(resolvedPath)
3521
+ };
3522
+ });
3523
+ const sqlDbs = unique(
3524
+ permissions.map((p) => p.sqlDb).filter((db) => Boolean(db))
3525
+ );
3526
+ const summary = {
3527
+ source,
3528
+ app_id: parsed.app_id,
3529
+ name: parsed.name,
3530
+ manifest_version: parsed.manifest_version,
3531
+ space: {
3532
+ name: spaceName,
3533
+ uri: spaceUri
3534
+ },
3535
+ permissions,
3536
+ sqlDatabases: sqlDbs
3537
+ };
3538
+ if (shouldOutputJson()) {
3539
+ outputJson(summary);
3540
+ return;
3541
+ }
3542
+ process.stdout.write(`${theme.heading("Manifest")}: ${theme.value(parsed.app_id)}`);
3543
+ if (parsed.name) process.stdout.write(theme.muted(` (${parsed.name})`));
3544
+ process.stdout.write("\n");
3545
+ process.stdout.write(`${theme.label("Space")}: ${theme.value(spaceName)}
3546
+ `);
3547
+ if (spaceUri) {
3548
+ process.stdout.write(`${theme.label("Space URI")}: ${theme.value(spaceUri)}
3549
+ `);
3550
+ }
3551
+ if (sqlDbs.length > 0) {
3552
+ process.stdout.write(`
3553
+ ${theme.heading("SQL databases")}
3554
+ `);
3555
+ for (const db of sqlDbs) {
3556
+ process.stdout.write(` ${theme.value(db)}
3557
+ `);
3558
+ }
3559
+ process.stdout.write(theme.muted(`
3560
+ Use with: tc sql query --space ${spaceName} --db <db> "..."
3561
+ `));
3562
+ }
3563
+ if (permissions.length > 0) {
3564
+ process.stdout.write(`
3565
+ ${theme.heading("Permissions")}
3566
+ `);
3567
+ const rows = permissions.map((p) => [p.service, p.path, p.actions.join(", ")]);
3568
+ process.stdout.write(formatTable(["service", "path", "actions"], rows) + "\n");
3569
+ }
3570
+ } catch (error) {
3571
+ handleError(error);
3572
+ }
3573
+ });
3574
+ }
3575
+ async function loadManifestSource(source) {
3576
+ if (/^https?:\/\//i.test(source)) {
3577
+ const response = await fetch(source);
3578
+ if (!response.ok) {
3579
+ throw new CLIError(
3580
+ "MANIFEST_FETCH_FAILED",
3581
+ `Failed to fetch manifest from ${source}: ${response.status} ${response.statusText}`,
3582
+ ExitCode.NETWORK_ERROR
3583
+ );
3584
+ }
3585
+ return response.text();
3586
+ }
3587
+ return readFile8(source, "utf8");
3588
+ }
3589
+ function prefixWithAppId(path, appId) {
3590
+ const slash = path.indexOf("/");
3591
+ if (slash === -1) return `${appId}/${path}`;
3592
+ const head = path.slice(0, slash);
3593
+ const tail = path.slice(slash + 1);
3594
+ return `${head}/${appId}/${tail}`;
3595
+ }
3596
+ function extractSqlDbName(path) {
3597
+ if (!path.startsWith("sql/")) return void 0;
3598
+ const rest = path.slice(4);
3599
+ const segments = rest.split("/");
3600
+ if (segments.length < 2) return rest;
3601
+ return segments.slice(0, -1).join("/");
3602
+ }
3603
+ function unique(arr) {
3604
+ return Array.from(new Set(arr));
3605
+ }
3606
+
2706
3607
  // src/commands/upgrade.ts
2707
3608
  import { execSync as execSync2 } from "child_process";
2708
- import { readFileSync } from "fs";
3609
+ import { readFileSync as readFileSync2 } from "fs";
2709
3610
  var PACKAGE_NAME = "@tinycloud/cli";
2710
3611
  function getCurrentVersion() {
2711
3612
  const pkg = JSON.parse(
2712
- readFileSync(new URL("../package.json", import.meta.url), "utf-8")
3613
+ readFileSync2(new URL("../package.json", import.meta.url), "utf-8")
2713
3614
  );
2714
3615
  return pkg.version;
2715
3616
  }
@@ -2773,7 +3674,7 @@ function registerUpgradeCommand(program2) {
2773
3674
 
2774
3675
  // src/index.ts
2775
3676
  var { version } = JSON.parse(
2776
- readFileSync2(new URL("../package.json", import.meta.url), "utf-8")
3677
+ readFileSync3(new URL("../package.json", import.meta.url), "utf-8")
2777
3678
  );
2778
3679
  var program = new Command();
2779
3680
  program.name("tc").description("TinyCloud CLI \u2014 self-sovereign storage from the terminal").version(version).option("-p, --profile <name>", "Profile to use").option("-H, --host <url>", "TinyCloud node URL").option("-v, --verbose", "Enable verbose output").option("--no-cache", "Disable caching").option("-q, --quiet", "Suppress non-essential output").option("--json", "Force JSON output");
@@ -2818,6 +3719,7 @@ registerVarsCommand(program);
2818
3719
  registerDoctorCommand(program);
2819
3720
  registerSqlCommand(program);
2820
3721
  registerDuckdbCommand(program);
3722
+ registerManifestCommand(program);
2821
3723
  registerUpgradeCommand(program);
2822
3724
  program.addHelpText("afterAll", () => {
2823
3725
  if (!process.stdout.isTTY) return "";