@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/README.md +20 -0
- package/dist/index.js +1012 -106
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { readFileSync as
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
439
|
+
const profileDir = join3(PROFILES_DIR, name);
|
|
355
440
|
await ensureDir(profileDir);
|
|
356
|
-
await writeJson(
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
481
|
+
const profileDir = join3(PROFILES_DIR, name);
|
|
397
482
|
await ensureDir(profileDir);
|
|
398
|
-
await writeJson(
|
|
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(
|
|
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 =
|
|
496
|
+
const profileDir = join3(PROFILES_DIR, name);
|
|
412
497
|
await ensureDir(profileDir);
|
|
413
|
-
await writeJson(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
() =>
|
|
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
|
-
() =>
|
|
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
|
-
() =>
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 "";
|