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