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

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,338 @@ 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 (err) {
929
+ if (process.env.TC_DEBUG_REPLAY === "1") {
930
+ process.stderr.write(`[replay] skipping ${entry.delegation.cid}: ${err.message}
931
+ `);
932
+ }
933
+ }
934
+ }
935
+ }
936
+ function storedAdditionalDelegation(delegation, permissions) {
937
+ return { delegation, permissions };
938
+ }
939
+ async function appendGrantHistory(profile, entry) {
940
+ const profileDir = join4(PROFILES_DIR, profile);
941
+ await ensureDir(profileDir);
942
+ const line = JSON.stringify({
943
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
944
+ profile,
945
+ ...entry
946
+ }) + "\n";
947
+ await appendFile(grantHistoryPath(profile), line, "utf8");
948
+ }
949
+ async function readGrantHistory(profile) {
950
+ const path = grantHistoryPath(profile);
951
+ if (!await fileExists(path)) return [];
952
+ const raw = await readFile2(path, "utf8");
953
+ return raw.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
954
+ }
955
+ async function parseCapSpec(spec, profile) {
956
+ const firstColon = spec.indexOf(":");
957
+ const lastColon = spec.lastIndexOf(":");
958
+ if (firstColon <= 0 || lastColon <= firstColon) {
959
+ throw new CLIError(
960
+ "INVALID_CAP",
961
+ `Invalid --cap "${spec}". Expected tinycloud.<service>:<space>:<path>:<actions-csv>.`,
962
+ ExitCode.USAGE_ERROR
963
+ );
964
+ }
965
+ const service = normalizeService(spec.slice(0, firstColon));
966
+ const actionsCsv = spec.slice(lastColon + 1);
967
+ const spaceAndPath = spec.slice(firstColon + 1, lastColon);
968
+ const { space, path } = splitSpaceAndPath(spaceAndPath);
969
+ const resolvedSpace = await resolveSpaceUri(space, profile) ?? space;
970
+ const actions = expandActionShortNames(
971
+ service,
972
+ actionsCsv.split(",").map((action) => action.trim()).filter(Boolean)
973
+ );
974
+ if (actions.length === 0) {
975
+ throw new CLIError("INVALID_CAP", `Capability "${spec}" has no actions.`, ExitCode.USAGE_ERROR);
976
+ }
977
+ return { service, space: resolvedSpace, path, actions };
978
+ }
979
+ async function loadPermissionRequest(source, profile) {
980
+ const raw = JSON.parse(await readFile2(source, "utf8"));
981
+ if (!Array.isArray(raw.permissions)) {
982
+ throw new CLIError(
983
+ "INVALID_PERMISSION_REQUEST",
984
+ `Permission request ${source} must contain { "permissions": [...] }.`,
985
+ ExitCode.USAGE_ERROR
986
+ );
987
+ }
988
+ return resolvePermissionSpaces(raw.permissions, profile);
989
+ }
990
+ async function loadManifestPermissions(source, profile) {
991
+ const raw = await loadManifestText(source);
992
+ const manifest = JSON.parse(raw);
993
+ if (typeof manifest.id === "string") {
994
+ const resolved = resolveManifest(manifest);
995
+ return resolvePermissionSpaces(resolved.resources, profile);
996
+ }
997
+ if (typeof manifest.app_id === "string") {
998
+ const permissions = (manifest.permissions ?? []).filter((entry) => entry !== null && typeof entry === "object").map((entry) => {
999
+ const service = normalizeService(String(entry.service ?? ""));
1000
+ const path = String(entry.path ?? "");
1001
+ const skipPrefix = entry.skipPrefix === true;
1002
+ const resolvedPath = skipPrefix ? path : prefixAppManifestPath(path, manifest.app_id);
1003
+ return {
1004
+ service,
1005
+ space: String(manifest.space ?? "applications"),
1006
+ path: resolvedPath,
1007
+ actions: expandActionShortNames(
1008
+ service,
1009
+ Array.isArray(entry.actions) ? entry.actions.map(String) : []
1010
+ )
1011
+ };
1012
+ });
1013
+ return resolvePermissionSpaces(permissions, profile);
1014
+ }
1015
+ throw new CLIError(
1016
+ "INVALID_MANIFEST",
1017
+ 'Manifest must contain either SDK field "id" or app manifest field "app_id".',
1018
+ ExitCode.USAGE_ERROR
1019
+ );
1020
+ }
1021
+ function permissionsFromDelegation(delegation) {
1022
+ if (delegation.resources?.length) {
1023
+ return delegation.resources.map((resource) => ({
1024
+ service: resource.service.startsWith("tinycloud.") ? resource.service : `tinycloud.${resource.service}`,
1025
+ space: resource.space,
1026
+ path: resource.path,
1027
+ actions: [...resource.actions]
1028
+ }));
1029
+ }
1030
+ return [{
1031
+ service: serviceFromActions(delegation.actions),
1032
+ space: delegation.spaceId,
1033
+ path: delegation.path,
1034
+ actions: [...delegation.actions]
1035
+ }];
1036
+ }
1037
+ function compactPermission(permission) {
1038
+ const service = permission.service;
1039
+ const space = permission.space.startsWith("tinycloud:") ? permission.space.slice(permission.space.lastIndexOf(":") + 1) : permission.space;
1040
+ const actions = permission.actions.map((action) => action.startsWith(`${service}/`) ? action.slice(service.length + 1) : action).join(",");
1041
+ return `${service}:${space}:${permission.path}:${actions}`;
1042
+ }
1043
+ async function resolvePermissionSpaces(entries, profile) {
1044
+ const resolved = [];
1045
+ for (const entry of entries) {
1046
+ const service = normalizeService(entry.service);
1047
+ resolved.push({
1048
+ ...entry,
1049
+ service,
1050
+ space: await resolveSpaceUri(entry.space, profile) ?? entry.space,
1051
+ actions: expandActionShortNames(service, entry.actions)
1052
+ });
1053
+ }
1054
+ return resolved;
1055
+ }
1056
+ async function loadManifestText(source) {
1057
+ if (source.startsWith("base64:")) {
1058
+ return Buffer.from(source.slice("base64:".length), "base64").toString("utf8");
1059
+ }
1060
+ if (await fileExists(source)) {
1061
+ return readFile2(source, "utf8");
1062
+ }
1063
+ try {
1064
+ const decoded = Buffer.from(source, "base64").toString("utf8");
1065
+ JSON.parse(decoded);
1066
+ return decoded;
1067
+ } catch {
1068
+ return readFile2(source, "utf8");
1069
+ }
1070
+ }
1071
+ function normalizeService(service) {
1072
+ if (!service) {
1073
+ throw new CLIError("INVALID_CAP", "Capability service is required.", ExitCode.USAGE_ERROR);
1074
+ }
1075
+ return service.startsWith("tinycloud.") ? service : `tinycloud.${service}`;
1076
+ }
1077
+ function splitSpaceAndPath(input) {
1078
+ if (input.startsWith("tinycloud:")) {
1079
+ const parts = input.split(":");
1080
+ if (parts.length < 7) {
1081
+ throw new CLIError(
1082
+ "INVALID_CAP",
1083
+ `Full tinycloud space specs must include a path after the space URI.`,
1084
+ ExitCode.USAGE_ERROR
1085
+ );
1086
+ }
1087
+ return {
1088
+ space: parts.slice(0, 6).join(":"),
1089
+ path: parts.slice(6).join(":")
1090
+ };
1091
+ }
1092
+ const colon = input.indexOf(":");
1093
+ if (colon <= 0) {
1094
+ throw new CLIError(
1095
+ "INVALID_CAP",
1096
+ `Capability must include both space and path.`,
1097
+ ExitCode.USAGE_ERROR
1098
+ );
1099
+ }
1100
+ return {
1101
+ space: input.slice(0, colon),
1102
+ path: input.slice(colon + 1)
1103
+ };
1104
+ }
1105
+ function prefixAppManifestPath(path, appId) {
1106
+ const slash = path.indexOf("/");
1107
+ if (slash === -1) return `${appId}/${path}`;
1108
+ return `${path.slice(0, slash)}/${appId}/${path.slice(slash + 1)}`;
1109
+ }
1110
+ function serviceFromActions(actions) {
1111
+ const first = actions[0] ?? "tinycloud.unknown/read";
1112
+ return first.includes("/") ? first.slice(0, first.indexOf("/")) : "tinycloud.unknown";
1113
+ }
1114
+
1115
+ // src/lib/sdk.ts
1116
+ async function createSDKInstance(ctx, options) {
1117
+ const profile = await ProfileManager.getProfile(ctx.profile);
1118
+ const session = await ProfileManager.getSession(ctx.profile);
1119
+ const key = await ProfileManager.getKey(ctx.profile);
1120
+ const effectivePrivateKey = options?.privateKey ?? profile.privateKey;
1121
+ if (!key && !effectivePrivateKey) {
1122
+ throw new CLIError(
1123
+ "AUTH_REQUIRED",
1124
+ `No key found for profile "${ctx.profile}". Run \`tc init\` first.`,
1125
+ ExitCode.AUTH_REQUIRED
1126
+ );
1127
+ }
1128
+ if (profile.authMethod === "local" && effectivePrivateKey) {
1129
+ const node2 = new TinyCloudNode({
1130
+ host: ctx.host,
1131
+ privateKey: effectivePrivateKey
1132
+ });
1133
+ await node2.signIn();
1134
+ await replayAdditionalDelegations(node2, ctx.profile);
1135
+ return node2;
1136
+ }
1137
+ const node = new TinyCloudNode({
1138
+ host: ctx.host,
1139
+ privateKey: options?.privateKey
1140
+ });
1141
+ if (options?.privateKey) {
1142
+ await node.signIn();
1143
+ } else if (session && session.delegationHeader && session.delegationCid && session.spaceId) {
1144
+ await node.restoreSession({
1145
+ delegationHeader: session.delegationHeader,
1146
+ delegationCid: session.delegationCid,
1147
+ spaceId: session.spaceId,
1148
+ jwk: session.jwk ?? key,
1149
+ verificationMethod: session.verificationMethod ?? profile.did,
1150
+ address: session.address,
1151
+ chainId: session.chainId,
1152
+ siwe: session.siwe,
1153
+ signature: session.signature
1154
+ });
1155
+ }
1156
+ await replayAdditionalDelegations(node, ctx.profile);
1157
+ return node;
1158
+ }
1159
+ async function ensureAuthenticated(ctx, options) {
1160
+ const profile = await ProfileManager.getProfile(ctx.profile).catch(() => null);
1161
+ if (profile?.authMethod === "local" && profile.privateKey) {
1162
+ return createSDKInstance(ctx, { privateKey: profile.privateKey });
1163
+ }
1164
+ const session = await ProfileManager.getSession(ctx.profile);
1165
+ if (!session) {
1166
+ throw new CLIError(
1167
+ "AUTH_REQUIRED",
1168
+ `Not authenticated. Run \`tc auth login\` or \`tc init\` first.`,
1169
+ ExitCode.AUTH_REQUIRED
1170
+ );
1171
+ }
1172
+ return createSDKInstance(ctx, options);
1173
+ }
1174
+
1175
+ // src/commands/auth.ts
1176
+ function resolveOpenKeyHost(profile) {
1177
+ return process.env.TC_OPENKEY_HOST ?? profile.openkeyHost ?? DEFAULT_OPENKEY_HOST;
1178
+ }
752
1179
  async function promptAuthMethod() {
753
1180
  if (!isInteractive()) {
754
1181
  return "local";
@@ -854,6 +1281,177 @@ function registerAuthCommand(program2) {
854
1281
  handleError(error);
855
1282
  }
856
1283
  });
1284
+ auth.command("request").description("Request additional TinyCloud permissions for the active session").option(
1285
+ "--cap <spec>",
1286
+ "Capability spec: tinycloud.<service>:<space>:<path>:<actions-csv> (repeatable)",
1287
+ (value, previous) => [...previous, value],
1288
+ []
1289
+ ).option("--permission <file>", 'JSON permission request: { "permissions": PermissionEntry[] }').option("--manifest <fileOrBase64>", "Manifest file, base64:<json>, or raw base64 JSON").option(
1290
+ "--expiry <duration>",
1291
+ `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.`
1292
+ ).option("--yes", "Skip local-key TTY confirmation", false).action(async (options, cmd) => {
1293
+ try {
1294
+ const globalOpts = cmd.optsWithGlobals();
1295
+ const ctx = await ProfileManager.resolveContext(globalOpts);
1296
+ const profile = await ProfileManager.getProfile(ctx.profile);
1297
+ const session = await ProfileManager.getSession(ctx.profile);
1298
+ const requested = await collectRequestedPermissions(options, ctx.profile);
1299
+ if (requested.length === 0) {
1300
+ throw new CLIError(
1301
+ "NO_CAPS_REQUESTED",
1302
+ "Provide at least one --cap, --permission, or --manifest.",
1303
+ ExitCode.USAGE_ERROR
1304
+ );
1305
+ }
1306
+ const node = await ensureAuthenticated(ctx);
1307
+ if (node.hasRuntimePermissions(requested)) {
1308
+ outputJson({ changed: false, missing: [], added: [] });
1309
+ return;
1310
+ }
1311
+ if (profile.authMethod === "openkey") {
1312
+ const key = await ProfileManager.getKey(ctx.profile);
1313
+ if (!key) {
1314
+ throw new CLIError("NO_KEY", `No key found for profile "${ctx.profile}". Run \`tc init\` first.`, ExitCode.AUTH_REQUIRED);
1315
+ }
1316
+ const delegationCids2 = [];
1317
+ let expiry2;
1318
+ const openkeyHost = resolveOpenKeyHost(profile);
1319
+ const expiryOption2 = parseExpiryOption(options.expiry);
1320
+ for (const group of groupPermissionsBySpace(requested)) {
1321
+ const delegationData = await startAuthFlow(profile.did, {
1322
+ jwk: key,
1323
+ host: ctx.host,
1324
+ permissions: group,
1325
+ openkeyHost,
1326
+ expiry: expiryOption2
1327
+ });
1328
+ const delegation = portableFromOpenKeyDelegation(delegationData, group, ctx.host);
1329
+ const stored = storedAdditionalDelegation(delegation, group);
1330
+ await appendAdditionalDelegation(ctx.profile, stored);
1331
+ await node.useRuntimeDelegation(delegation);
1332
+ delegationCids2.push(delegation.cid);
1333
+ expiry2 = delegation.expiry.toISOString();
1334
+ await appendGrantHistory(ctx.profile, {
1335
+ addedCaps: group,
1336
+ source: options.manifest ? "manifest" : "cli",
1337
+ delegationCid: delegation.cid,
1338
+ expiry: expiry2
1339
+ });
1340
+ }
1341
+ outputJson({
1342
+ changed: delegationCids2.length > 0,
1343
+ added: requested,
1344
+ delegationCid: delegationCids2[0],
1345
+ delegationCids: delegationCids2,
1346
+ expiry: expiry2
1347
+ });
1348
+ return;
1349
+ }
1350
+ if (isInteractive()) {
1351
+ if (!options.yes) {
1352
+ await confirmPermissionRequest(requested);
1353
+ }
1354
+ } else if (!options.yes) {
1355
+ throw new CLIError(
1356
+ "CONFIRMATION_REQUIRED",
1357
+ "Local-key permission requests in non-interactive mode require --yes.",
1358
+ ExitCode.USAGE_ERROR
1359
+ );
1360
+ }
1361
+ void session;
1362
+ const expiryOption = parseExpiryOption(options.expiry);
1363
+ const delegations = await node.grantRuntimePermissions(
1364
+ requested,
1365
+ expiryOption !== void 0 ? { expiry: expiryOption } : void 0
1366
+ );
1367
+ const delegationCids = [];
1368
+ let expiry;
1369
+ for (const delegation of delegations) {
1370
+ const covering = permissionsFromDelegation(delegation);
1371
+ const stored = storedAdditionalDelegation(delegation, covering);
1372
+ await appendAdditionalDelegation(ctx.profile, stored);
1373
+ delegationCids.push(delegation.cid);
1374
+ expiry = delegation.expiry.toISOString();
1375
+ await appendGrantHistory(ctx.profile, {
1376
+ addedCaps: covering,
1377
+ source: options.manifest ? "manifest" : "cli",
1378
+ delegationCid: delegation.cid,
1379
+ expiry
1380
+ });
1381
+ }
1382
+ if (delegationCids.length === 0) {
1383
+ outputJson({ changed: false, missing: [], added: [] });
1384
+ return;
1385
+ }
1386
+ outputJson({
1387
+ changed: true,
1388
+ added: requested,
1389
+ delegationCid: delegationCids[0],
1390
+ delegationCids,
1391
+ expiry
1392
+ });
1393
+ } catch (error) {
1394
+ handleError(error);
1395
+ }
1396
+ });
1397
+ 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) => {
1398
+ try {
1399
+ const globalOpts = cmd.optsWithGlobals();
1400
+ const ctx = await ProfileManager.resolveContext(globalOpts);
1401
+ if (options.history) {
1402
+ const history = (await readGrantHistory(ctx.profile)).slice(-20);
1403
+ if (shouldOutputJson()) {
1404
+ outputJson({ grants: history });
1405
+ } else if (history.length === 0) {
1406
+ process.stdout.write(theme.muted("No grant history.") + "\n");
1407
+ } else {
1408
+ process.stdout.write(formatTable(
1409
+ ["time", "source", "delegation", "caps"],
1410
+ history.map((entry) => [
1411
+ entry.ts,
1412
+ entry.source,
1413
+ entry.delegationCid ?? "",
1414
+ entry.addedCaps.map(compactPermission).join("; ")
1415
+ ])
1416
+ ) + "\n");
1417
+ }
1418
+ return;
1419
+ }
1420
+ const node = await ensureAuthenticated(ctx);
1421
+ const runtimeDelegations = node.getRuntimePermissionDelegations();
1422
+ const granted = runtimeDelegations.flatMap(permissionsFromDelegation);
1423
+ if (options.diff) {
1424
+ const requested = [await parseCapSpec(options.diff, ctx.profile)];
1425
+ const covered = node.hasRuntimePermissions(requested);
1426
+ outputJson({
1427
+ requested,
1428
+ changed: !covered,
1429
+ covered,
1430
+ // `missing` retained for backwards-compatible callers.
1431
+ missing: covered ? [] : requested
1432
+ });
1433
+ return;
1434
+ }
1435
+ const appended = await loadAdditionalDelegations(ctx.profile);
1436
+ if (shouldOutputJson()) {
1437
+ outputJson({ granted, appendedDelegations: appended.length });
1438
+ } else if (granted.length === 0) {
1439
+ process.stdout.write(theme.muted("No appended runtime delegations on this profile.") + "\n");
1440
+ } else {
1441
+ process.stdout.write(formatTable(
1442
+ ["service", "space", "path", "actions"],
1443
+ granted.map((entry) => [
1444
+ entry.service,
1445
+ entry.space,
1446
+ entry.path,
1447
+ entry.actions.join(", ")
1448
+ ])
1449
+ ) + "\n");
1450
+ }
1451
+ } catch (error) {
1452
+ handleError(error);
1453
+ }
1454
+ });
857
1455
  auth.command("whoami").description("Show identity information").action(async (_options, cmd) => {
858
1456
  try {
859
1457
  const globalOpts = cmd.optsWithGlobals();
@@ -888,6 +1486,111 @@ function registerAuthCommand(program2) {
888
1486
  }
889
1487
  });
890
1488
  }
1489
+ async function collectRequestedPermissions(options, profile) {
1490
+ const permissions = [];
1491
+ for (const spec of options.cap ?? []) {
1492
+ permissions.push(await parseCapSpec(spec, profile));
1493
+ }
1494
+ if (options.permission) {
1495
+ permissions.push(...await loadPermissionRequest(options.permission, profile));
1496
+ }
1497
+ if (options.manifest) {
1498
+ permissions.push(...await loadManifestPermissions(options.manifest, profile));
1499
+ }
1500
+ return permissions;
1501
+ }
1502
+ async function confirmPermissionRequest(permissions) {
1503
+ process.stderr.write("\n" + theme.heading("Additional Permissions") + "\n");
1504
+ for (const permission of permissions) {
1505
+ const dangerous = isDangerousPermission(permission);
1506
+ const line = ` ${compactPermission(permission)}`;
1507
+ process.stderr.write((dangerous ? theme.warn(line) : theme.value(line)) + "\n");
1508
+ }
1509
+ process.stderr.write("\n");
1510
+ const rl = createInterface2({
1511
+ input: process.stdin,
1512
+ output: process.stderr
1513
+ });
1514
+ const answer = await new Promise((resolve3) => {
1515
+ rl.question("Approve local-key delegation? [y/N] ", resolve3);
1516
+ });
1517
+ rl.close();
1518
+ if (!/^y(es)?$/i.test(answer.trim())) {
1519
+ throw new CLIError("REQUEST_CANCELLED", "Permission request cancelled.", ExitCode.ERROR);
1520
+ }
1521
+ }
1522
+ function isDangerousPermission(permission) {
1523
+ if (permission.path === "" || permission.path === "/") return true;
1524
+ return permission.actions.some(
1525
+ (action) => action.includes("*") || action.endsWith("/write") || action.endsWith("/admin") || action.endsWith("/ddl") || action.endsWith("/del")
1526
+ );
1527
+ }
1528
+ function parseExpiryOption(raw) {
1529
+ if (raw === void 0 || raw === null) return void 0;
1530
+ if (typeof raw !== "string" || raw.length === 0) {
1531
+ throw new CLIError(
1532
+ "INVALID_EXPIRY",
1533
+ `--expiry must be a string (e.g. "7d", "30m") or a millisecond integer.`,
1534
+ ExitCode.USAGE_ERROR
1535
+ );
1536
+ }
1537
+ if (/^\d+$/.test(raw.trim())) {
1538
+ const ms = Number(raw.trim());
1539
+ if (!Number.isFinite(ms) || ms <= 0) {
1540
+ throw new CLIError("INVALID_EXPIRY", `--expiry must be a positive integer when numeric.`, ExitCode.USAGE_ERROR);
1541
+ }
1542
+ return ms;
1543
+ }
1544
+ return raw;
1545
+ }
1546
+ function groupPermissionsBySpace(permissions) {
1547
+ const groups = /* @__PURE__ */ new Map();
1548
+ for (const permission of permissions) {
1549
+ const group = groups.get(permission.space) ?? [];
1550
+ group.push(permission);
1551
+ groups.set(permission.space, group);
1552
+ }
1553
+ return Array.from(groups.values());
1554
+ }
1555
+ function portableFromOpenKeyDelegation(data, permissions, host) {
1556
+ const primary = permissions[0];
1557
+ const returnedSpace = String(data.spaceId ?? primary.space);
1558
+ const expectedSpaces = new Set(permissions.map((permission) => permission.space));
1559
+ if (expectedSpaces.size !== 1 || !expectedSpaces.has(returnedSpace)) {
1560
+ throw new CLIError(
1561
+ "OPENKEY_SCOPE_MISMATCH",
1562
+ `OpenKey returned delegation for ${returnedSpace}, expected ${Array.from(expectedSpaces).join(", ")}.`,
1563
+ ExitCode.PERMISSION_DENIED
1564
+ );
1565
+ }
1566
+ const expiry = inferDelegationExpiry(data);
1567
+ return {
1568
+ cid: String(data.delegationCid),
1569
+ delegationHeader: data.delegationHeader,
1570
+ spaceId: returnedSpace,
1571
+ path: primary.path,
1572
+ actions: primary.actions,
1573
+ resources: permissions.map((permission) => ({
1574
+ service: permission.service.startsWith("tinycloud.") ? permission.service.slice("tinycloud.".length) : permission.service,
1575
+ space: permission.space,
1576
+ path: permission.path,
1577
+ actions: [...permission.actions]
1578
+ })),
1579
+ expiry,
1580
+ delegateDID: String(data.verificationMethod),
1581
+ ownerAddress: String(data.address ?? ""),
1582
+ chainId: typeof data.chainId === "number" ? data.chainId : DEFAULT_CHAIN_ID,
1583
+ host
1584
+ };
1585
+ }
1586
+ function inferDelegationExpiry(data) {
1587
+ const direct = data.expiry ?? data.expiresAt;
1588
+ if (typeof direct === "string") {
1589
+ const parsed = new Date(direct);
1590
+ if (!Number.isNaN(parsed.getTime())) return parsed;
1591
+ }
1592
+ return new Date(Date.now() + 60 * 60 * 1e3);
1593
+ }
891
1594
  async function handleLocalAuth(profileName, host) {
892
1595
  const profile = await ProfileManager.getProfile(profileName).catch(() => null);
893
1596
  let privateKey;
@@ -965,7 +1668,8 @@ async function handleOpenKeyAuth(profileName, host, paste) {
965
1668
  const delegationData = await startAuthFlow(profile.did, {
966
1669
  paste,
967
1670
  jwk: key,
968
- host
1671
+ host,
1672
+ openkeyHost: resolveOpenKeyHost(profile)
969
1673
  });
970
1674
  await ProfileManager.setSession(profileName, delegationData);
971
1675
  const updatedProfile = {
@@ -987,67 +1691,8 @@ async function handleOpenKeyAuth(profileName, host, paste) {
987
1691
  }
988
1692
 
989
1693
  // src/commands/kv.ts
990
- import { readFile as readFile2 } from "fs/promises";
1694
+ import { readFile as readFile3 } from "fs/promises";
991
1695
  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
1696
  async function readStdin() {
1052
1697
  const chunks = [];
1053
1698
  for await (const chunk of process.stdin) {
@@ -1110,7 +1755,7 @@ function registerKvCommand(program2) {
1110
1755
  throw new CLIError("USAGE_ERROR", "Provide only one of: value argument, --file, or --stdin", ExitCode.USAGE_ERROR);
1111
1756
  }
1112
1757
  if (options.file) {
1113
- putValue = await readFile2(options.file);
1758
+ putValue = await readFile3(options.file);
1114
1759
  } else if (options.stdin) {
1115
1760
  putValue = await readStdin();
1116
1761
  } else {
@@ -1823,7 +2468,7 @@ complete -c tc -l quiet -s q -d "Suppress non-essential output"
1823
2468
  }
1824
2469
 
1825
2470
  // src/commands/vault.ts
1826
- import { readFile as readFile3 } from "fs/promises";
2471
+ import { readFile as readFile4 } from "fs/promises";
1827
2472
  import { writeFile as writeFile3 } from "fs/promises";
1828
2473
  import { PrivateKeySigner as PrivateKeySigner2 } from "@tinycloud/node-sdk";
1829
2474
  async function readStdin2() {
@@ -1881,7 +2526,7 @@ function registerVaultCommand(program2) {
1881
2526
  throw new CLIError("USAGE_ERROR", "Provide only one of: value argument, --file, or --stdin", ExitCode.USAGE_ERROR);
1882
2527
  }
1883
2528
  if (options.file) {
1884
- putValue = new Uint8Array(await readFile3(options.file));
2529
+ putValue = new Uint8Array(await readFile4(options.file));
1885
2530
  } else if (options.stdin) {
1886
2531
  putValue = new Uint8Array(await readStdin2());
1887
2532
  } else {
@@ -1996,7 +2641,7 @@ function registerVaultCommand(program2) {
1996
2641
  }
1997
2642
 
1998
2643
  // src/commands/secrets.ts
1999
- import { readFile as readFile4 } from "fs/promises";
2644
+ import { readFile as readFile5 } from "fs/promises";
2000
2645
  import { writeFile as writeFile4 } from "fs/promises";
2001
2646
  import { PrivateKeySigner as PrivateKeySigner3 } from "@tinycloud/node-sdk";
2002
2647
  var SECRETS_PREFIX = "secrets/";
@@ -2123,7 +2768,7 @@ function registerSecretsCommand(program2) {
2123
2768
  throw new CLIError("USAGE_ERROR", "Provide only one of: value argument, --file, or --stdin", ExitCode.USAGE_ERROR);
2124
2769
  }
2125
2770
  if (options.file) {
2126
- secretValue = await readFile4(options.file, "utf-8");
2771
+ secretValue = await readFile5(options.file, "utf-8");
2127
2772
  } else if (options.stdin) {
2128
2773
  secretValue = (await readStdin3()).toString("utf-8");
2129
2774
  } else {
@@ -2172,7 +2817,7 @@ function registerSecretsCommand(program2) {
2172
2817
  }
2173
2818
 
2174
2819
  // src/commands/vars.ts
2175
- import { readFile as readFile5 } from "fs/promises";
2820
+ import { readFile as readFile6 } from "fs/promises";
2176
2821
  import { writeFile as writeFile5 } from "fs/promises";
2177
2822
  var VARIABLES_PREFIX = "variables/";
2178
2823
  async function readStdin4() {
@@ -2273,7 +2918,7 @@ function registerVarsCommand(program2) {
2273
2918
  throw new CLIError("USAGE_ERROR", "Provide only one of: value argument, --file, or --stdin", ExitCode.USAGE_ERROR);
2274
2919
  }
2275
2920
  if (options.file) {
2276
- varValue = await readFile5(options.file, "utf-8");
2921
+ varValue = await readFile6(options.file, "utf-8");
2277
2922
  } else if (options.stdin) {
2278
2923
  varValue = (await readStdin4()).toString("utf-8");
2279
2924
  } else {
@@ -2421,35 +3066,45 @@ function registerDoctorCommand(program2) {
2421
3066
  // src/commands/sql.ts
2422
3067
  import { writeFile as writeFile6 } from "fs/promises";
2423
3068
  import { resolve } from "path";
3069
+ async function dbHandle(node, dbName, spaceInput, profileName) {
3070
+ const spaceUri = await resolveSpaceUri(spaceInput, profileName);
3071
+ const sql = spaceUri ? node.sqlForSpace(spaceUri) : node.sql;
3072
+ return sql.db(dbName);
3073
+ }
2424
3074
  function registerSqlCommand(program2) {
2425
3075
  const sql = program2.command("sql").description("SQLite database operations for your TinyCloud space").addHelpText("after", `
2426
3076
 
2427
3077
  TinyCloud SQL gives each space isolated SQLite databases. Use the default
2428
- database for simple apps, or pass --db to target a named database.
3078
+ database for simple apps, or pass --db to target a named database. Pass
3079
+ --space to target a non-primary space (e.g. the manifest "applications" space).
2429
3080
 
2430
3081
  Common workflows:
2431
3082
  $ tc sql execute "CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)"
2432
3083
  $ tc sql execute "INSERT INTO notes (body) VALUES (?)" --params '["ship docs"]'
2433
3084
  $ tc sql query "SELECT id, body FROM notes ORDER BY id"
2434
3085
  $ tc sql query "SELECT * FROM events WHERE type = ?" --db analytics --params '["signup"]'
3086
+ $ tc sql query "SELECT count(*) FROM conversation" --space applications --db xyz.tinycloud.listen/conversations
2435
3087
  $ tc sql export --db analytics --output analytics.db
2436
3088
 
2437
3089
  Commands:
2438
3090
  query Read rows with SELECT statements
2439
3091
  execute Run writes and schema changes such as INSERT, UPDATE, DELETE, CREATE, DROP
2440
3092
  export Download the raw SQLite database file
3093
+ copy Copy rows between databases (optionally across spaces)
2441
3094
 
2442
3095
  Tips:
2443
3096
  - SQL strings should usually be quoted so your shell passes them as one argument.
2444
3097
  - --params accepts a JSON array and binds values to ? placeholders.
3098
+ - --space accepts a short name ("applications") or full URI ("tinycloud:pkh:eip155:1:0x...:applications").
2445
3099
  - Add --json for scripting-friendly output.
2446
3100
  `);
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", `
3101
+ 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
3102
 
2449
3103
  Examples:
2450
3104
  $ tc sql query "SELECT * FROM notes ORDER BY id"
2451
3105
  $ tc sql query "SELECT * FROM notes WHERE id = ?" --params '[42]'
2452
3106
  $ tc sql query "SELECT count(*) AS total FROM events" --db analytics --json
3107
+ $ tc sql query "SELECT count(*) FROM conversation" --space applications --db xyz.tinycloud.listen/conversations
2453
3108
 
2454
3109
  Output:
2455
3110
  Human output is formatted as a table. Piped output or --json returns
@@ -2460,12 +3115,13 @@ Output:
2460
3115
  const ctx = await ProfileManager.resolveContext(globalOpts);
2461
3116
  const node = await ensureAuthenticated(ctx);
2462
3117
  const params = options.params ? JSON.parse(options.params) : void 0;
3118
+ const handle = await dbHandle(node, options.db, options.space, ctx.profile);
2463
3119
  const result = await withSpinner(
2464
3120
  "Running query...",
2465
- () => node.sql.db(options.db).query(sqlStr, params)
3121
+ () => handle.query(sqlStr, params)
2466
3122
  );
2467
3123
  if (!result.ok) {
2468
- throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR);
3124
+ throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR, result.error.meta);
2469
3125
  }
2470
3126
  const { columns, rows, rowCount } = result.data;
2471
3127
  if (shouldOutputJson()) {
@@ -2486,13 +3142,14 @@ ${rowCount} row${rowCount === 1 ? "" : "s"} returned`) + "\n");
2486
3142
  handleError(error);
2487
3143
  }
2488
3144
  });
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", `
3145
+ 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
3146
 
2491
3147
  Examples:
2492
3148
  $ tc sql execute "CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)"
2493
3149
  $ tc sql execute "INSERT INTO notes (body) VALUES (?)" --params '["first note"]'
2494
3150
  $ tc sql execute "UPDATE notes SET body = ? WHERE id = ?" --params '["edited", 1]'
2495
3151
  $ tc sql execute "DROP TABLE old_notes" --db archive
3152
+ $ tc sql execute "DELETE FROM conversation WHERE id = ?" --space applications --db xyz.tinycloud.listen/conversations --params '["abc"]'
2496
3153
 
2497
3154
  Output:
2498
3155
  Returns JSON with the changed row count and last inserted row id when available.
@@ -2502,12 +3159,13 @@ Output:
2502
3159
  const ctx = await ProfileManager.resolveContext(globalOpts);
2503
3160
  const node = await ensureAuthenticated(ctx);
2504
3161
  const params = options.params ? JSON.parse(options.params) : void 0;
3162
+ const handle = await dbHandle(node, options.db, options.space, ctx.profile);
2505
3163
  const result = await withSpinner(
2506
3164
  "Executing statement...",
2507
- () => node.sql.db(options.db).execute(sqlStr, params)
3165
+ () => handle.execute(sqlStr, params)
2508
3166
  );
2509
3167
  if (!result.ok) {
2510
- throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR);
3168
+ throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR, result.error.meta);
2511
3169
  }
2512
3170
  outputJson({
2513
3171
  changes: result.data.changes,
@@ -2517,11 +3175,12 @@ Output:
2517
3175
  handleError(error);
2518
3176
  }
2519
3177
  });
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", `
3178
+ 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
3179
 
2522
3180
  Examples:
2523
3181
  $ tc sql export
2524
3182
  $ tc sql export --db analytics --output analytics.db
3183
+ $ tc sql export --space applications --db xyz.tinycloud.listen/conversations --output listen.db
2525
3184
 
2526
3185
  Output:
2527
3186
  Writes the database file locally and returns JSON with the path and size.
@@ -2530,12 +3189,13 @@ Output:
2530
3189
  const globalOpts = cmd.optsWithGlobals();
2531
3190
  const ctx = await ProfileManager.resolveContext(globalOpts);
2532
3191
  const node = await ensureAuthenticated(ctx);
3192
+ const handle = await dbHandle(node, options.db, options.space, ctx.profile);
2533
3193
  const result = await withSpinner(
2534
3194
  "Exporting database...",
2535
- () => node.sql.db(options.db).export()
3195
+ () => handle.export()
2536
3196
  );
2537
3197
  if (!result.ok) {
2538
- throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR);
3198
+ throw new CLIError(result.error.code, result.error.message, ExitCode.ERROR, result.error.meta);
2539
3199
  }
2540
3200
  const blob = result.data;
2541
3201
  const buffer = Buffer.from(await blob.arrayBuffer());
@@ -2550,10 +3210,126 @@ Output:
2550
3210
  handleError(error);
2551
3211
  }
2552
3212
  });
3213
+ 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", `
3214
+
3215
+ Examples:
3216
+ $ tc sql copy --from-db com.tinycloud.conversation-sync/conversations \\
3217
+ --to-db xyz.tinycloud.listen/conversations \\
3218
+ --space applications --dry-run
3219
+ $ tc sql copy --from-space applications --from-db com.foo/data \\
3220
+ --to-space applications --to-db com.bar/data \\
3221
+ --table conversation --table participant
3222
+
3223
+ Notes:
3224
+ - Refuses to run when (resolved space, db) is identical for source and destination.
3225
+ - Does NOT create destination tables. Run the target app once (or use \`tc sql execute\`)
3226
+ to materialize the schema before copying.
3227
+ - One row at a time; suitable for small/medium datasets. Large copies should
3228
+ use \`tc sql export\` + bulk import.
3229
+ - Authorization: the active session/delegation must cover sql/read on source
3230
+ AND sql/write on destination. Otherwise the relevant operation will fail.
3231
+ `).action(async (options, cmd) => {
3232
+ try {
3233
+ const globalOpts = cmd.optsWithGlobals();
3234
+ const ctx = await ProfileManager.resolveContext(globalOpts);
3235
+ const node = await ensureAuthenticated(ctx);
3236
+ const fromSpaceInput = options.fromSpace ?? options.space;
3237
+ const toSpaceInput = options.toSpace ?? options.space;
3238
+ const fromSpaceUri = await resolveSpaceUri(fromSpaceInput, ctx.profile) ?? "<primary>";
3239
+ const toSpaceUri = await resolveSpaceUri(toSpaceInput, ctx.profile) ?? "<primary>";
3240
+ if (fromSpaceUri === toSpaceUri && options.fromDb === options.toDb) {
3241
+ throw new CLIError(
3242
+ "SELF_COPY",
3243
+ `Refusing to copy: source and destination resolve to the same (space, db) \u2014 ${fromSpaceUri} / ${options.fromDb}.`,
3244
+ ExitCode.USAGE_ERROR
3245
+ );
3246
+ }
3247
+ const fromHandle = await dbHandle(node, options.fromDb, fromSpaceInput, ctx.profile);
3248
+ const toHandle = await dbHandle(node, options.toDb, toSpaceInput, ctx.profile);
3249
+ let tables;
3250
+ if (options.table && options.table.length > 0) {
3251
+ tables = options.table.flatMap((t) => t.split(",").map((s) => s.trim()).filter(Boolean));
3252
+ } else {
3253
+ const listing = await fromHandle.query(
3254
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
3255
+ );
3256
+ if (!listing.ok) {
3257
+ throw new CLIError(listing.error.code, `Cannot list source tables: ${listing.error.message}`, ExitCode.ERROR, listing.error.meta);
3258
+ }
3259
+ tables = listing.data.rows.map((r) => String(r[0]));
3260
+ }
3261
+ if (tables.length === 0) {
3262
+ throw new CLIError(
3263
+ "EMPTY_PLAN",
3264
+ `No tables to copy. Use --table to specify tables, or check that the source database has user tables.`,
3265
+ ExitCode.USAGE_ERROR
3266
+ );
3267
+ }
3268
+ const plan = [];
3269
+ for (const table of tables) {
3270
+ const safe = quoteIdent(table);
3271
+ const countResult = await fromHandle.query(`SELECT count(*) AS n FROM ${safe}`);
3272
+ if (!countResult.ok) {
3273
+ throw new CLIError(
3274
+ countResult.error.code,
3275
+ `Cannot count rows in source table "${table}": ${countResult.error.message}`,
3276
+ ExitCode.ERROR,
3277
+ countResult.error.meta
3278
+ );
3279
+ }
3280
+ const rows = Number(countResult.data.rows[0]?.[0] ?? 0);
3281
+ plan.push({ table, rows, copied: 0, skipped: 0 });
3282
+ }
3283
+ if (options.dryRun) {
3284
+ outputJson({
3285
+ dryRun: true,
3286
+ from: { space: fromSpaceUri, db: options.fromDb },
3287
+ to: { space: toSpaceUri, db: options.toDb },
3288
+ tables: plan.map((p) => ({ table: p.table, rows: p.rows }))
3289
+ });
3290
+ return;
3291
+ }
3292
+ for (const entry of plan) {
3293
+ const safe = quoteIdent(entry.table);
3294
+ const fetched = await fromHandle.query(`SELECT * FROM ${safe}`);
3295
+ if (!fetched.ok) {
3296
+ throw new CLIError(fetched.error.code, `Failed to read "${entry.table}": ${fetched.error.message}`, ExitCode.ERROR, fetched.error.meta);
3297
+ }
3298
+ const columns = fetched.data.columns;
3299
+ const rows = fetched.data.rows;
3300
+ if (rows.length === 0) continue;
3301
+ const colList = columns.map(quoteIdent).join(", ");
3302
+ const placeholders = columns.map(() => "?").join(", ");
3303
+ const insertSql = `INSERT INTO ${safe} (${colList}) VALUES (${placeholders})`;
3304
+ for (const row of rows) {
3305
+ const writeResult = await toHandle.execute(insertSql, row);
3306
+ if (!writeResult.ok) {
3307
+ throw new CLIError(
3308
+ writeResult.error.code,
3309
+ `Insert into "${entry.table}" failed after ${entry.copied} row(s): ${writeResult.error.message}`,
3310
+ ExitCode.ERROR,
3311
+ writeResult.error.meta
3312
+ );
3313
+ }
3314
+ entry.copied += writeResult.data.changes ?? 1;
3315
+ }
3316
+ }
3317
+ outputJson({
3318
+ from: { space: fromSpaceUri, db: options.fromDb },
3319
+ to: { space: toSpaceUri, db: options.toDb },
3320
+ tables: plan.map((p) => ({ table: p.table, rowsRead: p.rows, rowsWritten: p.copied }))
3321
+ });
3322
+ } catch (error) {
3323
+ handleError(error);
3324
+ }
3325
+ });
3326
+ }
3327
+ function quoteIdent(name) {
3328
+ return `"${name.replace(/"/g, '""')}"`;
2553
3329
  }
2554
3330
 
2555
3331
  // src/commands/duckdb.ts
2556
- import { readFile as readFile6, writeFile as writeFile7 } from "fs/promises";
3332
+ import { readFile as readFile7, writeFile as writeFile7 } from "fs/promises";
2557
3333
  import { resolve as resolve2 } from "path";
2558
3334
  function registerDuckdbCommand(program2) {
2559
3335
  const duckdb = program2.command("duckdb").description("DuckDB database operations");
@@ -2683,7 +3459,7 @@ ${rowCount} row${rowCount === 1 ? "" : "s"} returned`) + "\n");
2683
3459
  const ctx = await ProfileManager.resolveContext(globalOpts);
2684
3460
  const node = await ensureAuthenticated(ctx);
2685
3461
  const filePath = resolve2(file);
2686
- const bytes = new Uint8Array(await readFile6(filePath));
3462
+ const bytes = new Uint8Array(await readFile7(filePath));
2687
3463
  const result = await withSpinner(
2688
3464
  "Importing database...",
2689
3465
  () => node.duckdb.db(options.db).import(bytes)
@@ -2703,13 +3479,142 @@ ${rowCount} row${rowCount === 1 ? "" : "s"} returned`) + "\n");
2703
3479
  });
2704
3480
  }
2705
3481
 
3482
+ // src/commands/manifest.ts
3483
+ import { readFile as readFile8 } from "fs/promises";
3484
+ var DEFAULT_APP_SPACE = "applications";
3485
+ function registerManifestCommand(program2) {
3486
+ const manifest = program2.command("manifest").description("Inspect TinyCloud app manifests");
3487
+ manifest.command("resolve <source>").description("Resolve a manifest file or URL to its effective space, paths, and DB basenames").addHelpText("after", `
3488
+
3489
+ Examples:
3490
+ $ tc manifest resolve ./manifest.json
3491
+ $ tc manifest resolve https://app.example.com/manifest.json --json
3492
+
3493
+ What it shows:
3494
+ - app_id, name, manifest_version
3495
+ - effective space name (default: "applications") and full space URI for the active profile
3496
+ - per-permission: service, fully-qualified path, actions
3497
+ - inferred SQL database basenames for sql/<db>/... paths
3498
+
3499
+ This command is read-only and does NOT contact the node \u2014 it just resolves
3500
+ the manifest against the active profile's address/chain so you know which
3501
+ \`--space\` and \`--db\` values to pass to other tc commands.
3502
+ `).action(async (source, _options, cmd) => {
3503
+ try {
3504
+ const globalOpts = cmd.optsWithGlobals();
3505
+ const ctx = await ProfileManager.resolveContext(globalOpts);
3506
+ const raw = await loadManifestSource(source);
3507
+ const parsed = JSON.parse(raw);
3508
+ if (!parsed.app_id) {
3509
+ throw new CLIError(
3510
+ "INVALID_MANIFEST",
3511
+ `Manifest is missing required field "app_id".`,
3512
+ ExitCode.ERROR
3513
+ );
3514
+ }
3515
+ await ensureAuthenticated(ctx);
3516
+ const spaceName = parsed.space ?? DEFAULT_APP_SPACE;
3517
+ const spaceUri = await resolveSpaceUri(spaceName, ctx.profile);
3518
+ const permissions = (parsed.permissions ?? []).map((p) => {
3519
+ const resolvedPath = p.skipPrefix ? p.path : prefixWithAppId(p.path, parsed.app_id);
3520
+ return {
3521
+ service: p.service,
3522
+ path: resolvedPath,
3523
+ actions: p.actions,
3524
+ sqlDb: extractSqlDbName(resolvedPath)
3525
+ };
3526
+ });
3527
+ const sqlDbs = unique(
3528
+ permissions.map((p) => p.sqlDb).filter((db) => Boolean(db))
3529
+ );
3530
+ const summary = {
3531
+ source,
3532
+ app_id: parsed.app_id,
3533
+ name: parsed.name,
3534
+ manifest_version: parsed.manifest_version,
3535
+ space: {
3536
+ name: spaceName,
3537
+ uri: spaceUri
3538
+ },
3539
+ permissions,
3540
+ sqlDatabases: sqlDbs
3541
+ };
3542
+ if (shouldOutputJson()) {
3543
+ outputJson(summary);
3544
+ return;
3545
+ }
3546
+ process.stdout.write(`${theme.heading("Manifest")}: ${theme.value(parsed.app_id)}`);
3547
+ if (parsed.name) process.stdout.write(theme.muted(` (${parsed.name})`));
3548
+ process.stdout.write("\n");
3549
+ process.stdout.write(`${theme.label("Space")}: ${theme.value(spaceName)}
3550
+ `);
3551
+ if (spaceUri) {
3552
+ process.stdout.write(`${theme.label("Space URI")}: ${theme.value(spaceUri)}
3553
+ `);
3554
+ }
3555
+ if (sqlDbs.length > 0) {
3556
+ process.stdout.write(`
3557
+ ${theme.heading("SQL databases")}
3558
+ `);
3559
+ for (const db of sqlDbs) {
3560
+ process.stdout.write(` ${theme.value(db)}
3561
+ `);
3562
+ }
3563
+ process.stdout.write(theme.muted(`
3564
+ Use with: tc sql query --space ${spaceName} --db <db> "..."
3565
+ `));
3566
+ }
3567
+ if (permissions.length > 0) {
3568
+ process.stdout.write(`
3569
+ ${theme.heading("Permissions")}
3570
+ `);
3571
+ const rows = permissions.map((p) => [p.service, p.path, p.actions.join(", ")]);
3572
+ process.stdout.write(formatTable(["service", "path", "actions"], rows) + "\n");
3573
+ }
3574
+ } catch (error) {
3575
+ handleError(error);
3576
+ }
3577
+ });
3578
+ }
3579
+ async function loadManifestSource(source) {
3580
+ if (/^https?:\/\//i.test(source)) {
3581
+ const response = await fetch(source);
3582
+ if (!response.ok) {
3583
+ throw new CLIError(
3584
+ "MANIFEST_FETCH_FAILED",
3585
+ `Failed to fetch manifest from ${source}: ${response.status} ${response.statusText}`,
3586
+ ExitCode.NETWORK_ERROR
3587
+ );
3588
+ }
3589
+ return response.text();
3590
+ }
3591
+ return readFile8(source, "utf8");
3592
+ }
3593
+ function prefixWithAppId(path, appId) {
3594
+ const slash = path.indexOf("/");
3595
+ if (slash === -1) return `${appId}/${path}`;
3596
+ const head = path.slice(0, slash);
3597
+ const tail = path.slice(slash + 1);
3598
+ return `${head}/${appId}/${tail}`;
3599
+ }
3600
+ function extractSqlDbName(path) {
3601
+ if (!path.startsWith("sql/")) return void 0;
3602
+ const rest = path.slice(4);
3603
+ const segments = rest.split("/");
3604
+ if (segments.length < 2) return rest;
3605
+ return segments.slice(0, -1).join("/");
3606
+ }
3607
+ function unique(arr) {
3608
+ return Array.from(new Set(arr));
3609
+ }
3610
+
2706
3611
  // src/commands/upgrade.ts
2707
3612
  import { execSync as execSync2 } from "child_process";
2708
- import { readFileSync } from "fs";
3613
+ import { readFileSync as readFileSync2 } from "fs";
2709
3614
  var PACKAGE_NAME = "@tinycloud/cli";
2710
3615
  function getCurrentVersion() {
2711
3616
  const pkg = JSON.parse(
2712
- readFileSync(new URL("../package.json", import.meta.url), "utf-8")
3617
+ readFileSync2(new URL("../package.json", import.meta.url), "utf-8")
2713
3618
  );
2714
3619
  return pkg.version;
2715
3620
  }
@@ -2773,7 +3678,7 @@ function registerUpgradeCommand(program2) {
2773
3678
 
2774
3679
  // src/index.ts
2775
3680
  var { version } = JSON.parse(
2776
- readFileSync2(new URL("../package.json", import.meta.url), "utf-8")
3681
+ readFileSync3(new URL("../package.json", import.meta.url), "utf-8")
2777
3682
  );
2778
3683
  var program = new Command();
2779
3684
  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 +3723,7 @@ registerVarsCommand(program);
2818
3723
  registerDoctorCommand(program);
2819
3724
  registerSqlCommand(program);
2820
3725
  registerDuckdbCommand(program);
3726
+ registerManifestCommand(program);
2821
3727
  registerUpgradeCommand(program);
2822
3728
  program.addHelpText("afterAll", () => {
2823
3729
  if (!process.stdout.isTTY) return "";