@tinycloud/cli 1.0.0-beta.16 → 1.0.0-beta.17

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 CHANGED
@@ -8,7 +8,16 @@ Self-sovereign storage from the terminal. `tc` is the command-line interface for
8
8
  npm install -g @tinycloud/cli
9
9
  ```
10
10
 
11
- Requires Node.js >= 20.
11
+ Requires Node.js >= 20. On Linux, `/usr/sbin/tc` (iproute2) can shadow this
12
+ CLI; call it as `"$(npm prefix --global)/bin/tc"` when in doubt.
13
+
14
+ ## Agent skills
15
+
16
+ The package ships the general `tc-cli` skill and its references in
17
+ `skills/tc-cli`. See [skill installation](skills/tc-cli/INSTALL.md) for a
18
+ pinned, cross-agent installation with OpenCode, Codex and Claude Code, and
19
+ [authentication](skills/tc-cli/AUTH.md) for device login and relaying the
20
+ approval code to the owner.
12
21
 
13
22
  ## Operations coverage
14
23
 
@@ -34,11 +43,17 @@ tc init
34
43
  # Authenticate via browser
35
44
  tc auth login
36
45
 
37
- # Authenticate on a remote/headless machine
38
- tc auth login --device
46
+ # Authenticate on a remote/headless machine: the owner approves an explicit
47
+ # manifest on their phone (URL + code printed to stderr). Use a new profile
48
+ # name if it already exists; keep the user's existing profiles.
49
+ tc init --name publisher --key-only
50
+ tc --profile publisher auth login --device --manifest builtin:share-publishing
51
+
52
+ # Same Share publishing scope, as a shortcut (on the dedicated profile)
53
+ tc --profile publisher enable share
39
54
 
40
- # Enable only Share publishing authority
41
- tc enable share
55
+ # Selected profile, owner, host and space (JSON; does not test access)
56
+ tc context
42
57
 
43
58
  # Store and retrieve data
44
59
  tc kv put greeting "Hello, world"
@@ -64,8 +79,10 @@ tc delegation create --to did:pkh:eip155:1:0x...
64
79
  |---------|-------------|
65
80
  | `tc init` | Set up a profile and generate keys |
66
81
  | `tc auth login` | Authenticate via browser |
67
- | `tc auth login --device` | Authenticate with an OpenKey URL and user code |
68
- | `tc enable share` | Enable narrowly scoped Share publishing authority |
82
+ | `tc auth login --device --manifest <file>` | Approve exactly a manifest's permissions on another device (phone) |
83
+ | `tc auth login --manifest <file>` | Browser OpenKey login scoped to a manifest |
84
+ | `tc enable share` | Device login with the built-in Share publishing manifest (use a dedicated profile) |
85
+ | `tc context` | Show the selected profile, owner, host, space and session state |
69
86
  | `tc auth status` | Show authentication status |
70
87
  | `tc auth whoami` | Show current identity |
71
88
  | `tc kv get <key>` | Retrieve a value |
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ var init_constants = __esm({
21
21
  CONFIG_DIR = tinycloudHomePath();
22
22
  PROFILES_DIR = profilesPath();
23
23
  CONFIG_FILE = tinycloudConfigPath();
24
- DEFAULT_HOST = "https://node.tinycloud.xyz";
24
+ DEFAULT_HOST = "https://tee.node.tinycloud.xyz";
25
25
  DEFAULT_PROFILE = "default";
26
26
  ExitCode = {
27
27
  SUCCESS: 0,
@@ -104,6 +104,7 @@ var init_formatter = __esm({
104
104
  // src/output/errors.ts
105
105
  import { readFileSync, readdirSync } from "fs";
106
106
  import { join } from "path";
107
+ import { ProfileLockTimeoutError } from "@tinycloud/operations/state";
107
108
  function setActiveProfileName(name) {
108
109
  activeProfileName = name;
109
110
  }
@@ -121,6 +122,14 @@ function wrapError(error) {
121
122
  );
122
123
  }
123
124
  if (error instanceof CLIError) return error;
125
+ if (error instanceof ProfileLockTimeoutError) {
126
+ return new CLIError(
127
+ "PROFILE_LOCK_TIMEOUT",
128
+ `${message} Another tc or MCP process held this profile's lock, so the change that needed it was not written.`,
129
+ ExitCode.ERROR,
130
+ { hint: "Wait for the other command to finish and retry. A crashed process's lock is reclaimed automatically after 30 s." }
131
+ );
132
+ }
124
133
  if (message.includes("Not signed in") || message.includes("AUTH_EXPIRED") || message.includes("Session expired")) {
125
134
  return new CLIError("AUTH_REQUIRED", message, ExitCode.AUTH_REQUIRED);
126
135
  }
@@ -244,9 +253,9 @@ async function readJson(filePath) {
244
253
  async function writeJson(filePath, data) {
245
254
  const directory = dirname(filePath);
246
255
  const tempPath = join2(directory, `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
247
- await mkdir(directory, { recursive: true });
256
+ await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
248
257
  try {
249
- await writeFile(tempPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
258
+ await writeFile(tempPath, JSON.stringify(data, null, 2) + "\n", { encoding: "utf-8", mode: PRIVATE_FILE_MODE });
250
259
  await rename(tempPath, filePath);
251
260
  } catch (err) {
252
261
  await rm(tempPath, { force: true }).catch(() => void 0);
@@ -265,7 +274,7 @@ async function fileExists(filePath) {
265
274
  }
266
275
  }
267
276
  async function ensureDir(dirPath) {
268
- await mkdir(dirPath, { recursive: true });
277
+ await mkdir(dirPath, { recursive: true, mode: PRIVATE_DIR_MODE });
269
278
  }
270
279
  async function removeDir(dirPath) {
271
280
  await rm(dirPath, { recursive: true, force: true });
@@ -281,9 +290,12 @@ async function listDirs(dirPath) {
281
290
  throw err;
282
291
  }
283
292
  }
293
+ var PRIVATE_FILE_MODE, PRIVATE_DIR_MODE;
284
294
  var init_storage = __esm({
285
295
  "src/config/storage.ts"() {
286
296
  "use strict";
297
+ PRIVATE_FILE_MODE = 384;
298
+ PRIVATE_DIR_MODE = 448;
287
299
  }
288
300
  });
289
301
 
@@ -419,17 +431,14 @@ function profileLocalNodeIdentityStore(profileName) {
419
431
  return profile?.pinnedLocalNodeDids?.[url];
420
432
  },
421
433
  set: async (url, nodeDid) => {
422
- const profile = await ProfileManager.getProfile(profileName).catch(
423
- () => null
424
- );
425
- if (!profile) return;
426
- await ProfileManager.setProfile(profileName, {
434
+ if (!await ProfileManager.profileExists(profileName)) return;
435
+ await ProfileManager.updateProfile(profileName, (profile) => ({
427
436
  ...profile,
428
437
  pinnedLocalNodeDids: {
429
438
  ...profile.pinnedLocalNodeDids,
430
439
  [url]: nodeDid
431
440
  }
432
- });
441
+ }));
433
442
  }
434
443
  };
435
444
  }
@@ -524,10 +533,12 @@ var init_host = __esm({
524
533
  });
525
534
 
526
535
  // src/config/profiles.ts
536
+ import { chmod, rm as rm2 } from "fs/promises";
527
537
  import { join as join3 } from "path";
528
538
  import {
529
539
  readSession,
530
540
  removeSession,
541
+ withProfileLock,
531
542
  writeSession
532
543
  } from "@tinycloud/operations/state";
533
544
  var ProfileManager;
@@ -540,11 +551,41 @@ var init_profiles = __esm({
540
551
  ProfileManager = class _ProfileManager {
541
552
  // ── Initialization ──────────────────────────────────────────────────
542
553
  /**
543
- * Creates ~/.tinycloud/ and ~/.tinycloud/profiles/ if they don't exist.
554
+ * Runs `action` holding the profile's store lock (shared with operations
555
+ * and MCP). Reentrant, so the writers below can be called inside it; hold
556
+ * it around a whole read-modify-write, not just each write.
557
+ */
558
+ static async withLock(name, action, options) {
559
+ return withProfileLock(name, action, options);
560
+ }
561
+ /**
562
+ * Read-modify-write of profile.json under the profile lock, so concurrent
563
+ * updates (another command, a login commit) are never erased.
564
+ */
565
+ static async updateProfile(name, update) {
566
+ return _ProfileManager.withLock(name, async () => {
567
+ const next = update(await _ProfileManager.getProfile(name));
568
+ await _ProfileManager.setProfile(name, next);
569
+ return next;
570
+ });
571
+ }
572
+ /**
573
+ * Creates ~/.tinycloud/ and ~/.tinycloud/profiles/ if they don't exist and
574
+ * (re)sets both to 0700: older releases created them 0775.
544
575
  */
545
576
  static async ensureConfigDir() {
546
- await ensureDir(CONFIG_DIR);
547
- await ensureDir(PROFILES_DIR);
577
+ for (const directory of [CONFIG_DIR, PROFILES_DIR]) {
578
+ await ensureDir(directory);
579
+ await chmod(directory, PRIVATE_DIR_MODE);
580
+ }
581
+ }
582
+ /** Owner-only profile directory (0700), created or tightened before any write into it. */
583
+ static async ensureProfileDir(name) {
584
+ await _ProfileManager.ensureConfigDir();
585
+ const profileDir = join3(PROFILES_DIR, name);
586
+ await ensureDir(profileDir);
587
+ await chmod(profileDir, PRIVATE_DIR_MODE);
588
+ return profileDir;
548
589
  }
549
590
  // ── Global config ───────────────────────────────────────────────────
550
591
  /**
@@ -581,12 +622,17 @@ var init_profiles = __esm({
581
622
  return profile;
582
623
  }
583
624
  /**
584
- * Saves a profile config, creating the profile directory if needed.
625
+ * Saves a profile config under the profile lock, creating the profile
626
+ * directory if needed. Use `updateProfile` to change an existing profile.
585
627
  */
586
628
  static async setProfile(name, data) {
587
- const profileDir = join3(PROFILES_DIR, name);
588
- await ensureDir(profileDir);
589
- await writeJson(join3(profileDir, "profile.json"), data);
629
+ await _ProfileManager.withLock(name, async () => {
630
+ await writeJson(join3(await _ProfileManager.ensureProfileDir(name), "profile.json"), data);
631
+ });
632
+ }
633
+ /** Removes profile.json under the profile lock (rollback of a profile a failed login created). */
634
+ static async removeProfileConfig(name) {
635
+ await _ProfileManager.withLock(name, () => rm2(join3(PROFILES_DIR, name, "profile.json"), { force: true }));
590
636
  }
591
637
  /**
592
638
  * Returns true if a profile directory exists.
@@ -622,13 +668,15 @@ var init_profiles = __esm({
622
668
  static async getKey(name) {
623
669
  return readJson(join3(PROFILES_DIR, name, "key.json"));
624
670
  }
625
- /**
626
- * Saves a JWK key for a profile.
627
- */
671
+ /** Saves a JWK key under the profile lock (0600, in an owner-only profile directory). */
628
672
  static async setKey(name, jwk) {
629
- const profileDir = join3(PROFILES_DIR, name);
630
- await ensureDir(profileDir);
631
- await writeJson(join3(profileDir, "key.json"), jwk);
673
+ await _ProfileManager.withLock(name, async () => {
674
+ await writeJson(join3(await _ProfileManager.ensureProfileDir(name), "key.json"), jwk);
675
+ });
676
+ }
677
+ /** Removes key.json under the profile lock (rollback of a key a failed login created). */
678
+ static async removeKey(name) {
679
+ await _ProfileManager.withLock(name, () => rm2(join3(PROFILES_DIR, name, "key.json"), { force: true }));
632
680
  }
633
681
  // ── Session management ──────────────────────────────────────────────
634
682
  /**
@@ -651,11 +699,13 @@ var init_profiles = __esm({
651
699
  }
652
700
  // ── Cache management ────────────────────────────────────────────────
653
701
  /**
654
- * Returns the path to the profile's cache directory, creating it if needed.
702
+ * Returns the profile's cache directory (share history lives here),
703
+ * created or tightened to 0700.
655
704
  */
656
705
  static async getCacheDir(name) {
657
- const cacheDir = join3(PROFILES_DIR, name, "cache");
706
+ const cacheDir = join3(await _ProfileManager.ensureProfileDir(name), "cache");
658
707
  await ensureDir(cacheDir);
708
+ await chmod(cacheDir, PRIVATE_DIR_MODE);
659
709
  return cacheDir;
660
710
  }
661
711
  // ── Resolution helpers ──────────────────────────────────────────────
@@ -5535,8 +5585,15 @@ var init_zod = __esm({
5535
5585
  }
5536
5586
  });
5537
5587
 
5588
+ // src/share/publishing-manifest.ts
5589
+ var init_publishing_manifest = __esm({
5590
+ "src/share/publishing-manifest.ts"() {
5591
+ "use strict";
5592
+ }
5593
+ });
5594
+
5538
5595
  // src/lib/permissions.ts
5539
- import { appendFile, readFile as readFile3 } from "fs/promises";
5596
+ import { appendFile, chmod as chmod2, readFile as readFile3 } from "fs/promises";
5540
5597
  import { join as join5 } from "path";
5541
5598
  import {
5542
5599
  buildPermissionRequestArtifact,
@@ -5549,7 +5606,7 @@ import {
5549
5606
  readAdditionalDelegations,
5550
5607
  readAuthRequests,
5551
5608
  upsertProfileRecord,
5552
- withProfileLock,
5609
+ withProfileLock as withProfileLock2,
5553
5610
  writeJsonAtomic
5554
5611
  } from "@tinycloud/operations/state";
5555
5612
  async function loadAdditionalDelegations(profile) {
@@ -5579,6 +5636,7 @@ var init_permissions = __esm({
5579
5636
  init_errors();
5580
5637
  init_constants();
5581
5638
  init_space();
5639
+ init_publishing_manifest();
5582
5640
  init_types();
5583
5641
  }
5584
5642
  });
@@ -5694,17 +5752,19 @@ async function bootstrapDelegatedSession(ctx, delegation) {
5694
5752
  }
5695
5753
  const key = await ProfileManager.getKey(ctx.profile);
5696
5754
  const jwk = signerJwkForProfile(ctx.profile, void 0, key);
5697
- await ProfileManager.setSession(ctx.profile, {
5698
- delegationHeader: delegation.delegationHeader,
5699
- delegationCid: delegation.cid,
5700
- spaceId: delegation.spaceId,
5701
- jwk,
5702
- verificationMethod: sessionDid
5703
- });
5704
- await ProfileManager.setProfile(ctx.profile, {
5705
- ...profile,
5706
- sessionDid,
5707
- spaceId: delegation.spaceId
5755
+ await ProfileManager.withLock(ctx.profile, async () => {
5756
+ await ProfileManager.setSession(ctx.profile, {
5757
+ delegationHeader: delegation.delegationHeader,
5758
+ delegationCid: delegation.cid,
5759
+ spaceId: delegation.spaceId,
5760
+ jwk,
5761
+ verificationMethod: sessionDid
5762
+ });
5763
+ await ProfileManager.updateProfile(ctx.profile, (current) => ({
5764
+ ...current,
5765
+ sessionDid,
5766
+ spaceId: delegation.spaceId
5767
+ }));
5708
5768
  });
5709
5769
  return createSDKInstance(ctx);
5710
5770
  }
@@ -15030,7 +15090,7 @@ function receiveJson(result, path) {
15030
15090
 
15031
15091
  // src/share/io.ts
15032
15092
  import { constants } from "fs";
15033
- import { lstat, mkdir as mkdir2, mkdtemp, open as open2, readFile as readFile2, realpath, stat as stat2, link, rename as rename2, rm as rm2, unlink } from "fs/promises";
15093
+ import { lstat, mkdir as mkdir2, mkdtemp, open as open2, readFile as readFile2, realpath, stat as stat2, link, rename as rename2, rm as rm3, unlink } from "fs/promises";
15034
15094
  import { randomBytes as randomBytes2 } from "crypto";
15035
15095
  import { basename as basename2, join as join4, resolve, sep } from "path";
15036
15096
  var MAX_SHARE_STDIN_BYTES = 100 * 1024 * 1024;
@@ -15144,7 +15204,7 @@ async function writeShareOutput(directory, filename, bytes, force) {
15144
15204
  if (temporaryPath !== void 0) await unlink(temporaryPath);
15145
15205
  } catch {
15146
15206
  }
15147
- await rm2(stagingDirectory, { recursive: true, force: true });
15207
+ await rm3(stagingDirectory, { recursive: true, force: true });
15148
15208
  await directoryHandle.close();
15149
15209
  }
15150
15210
  return join4(outputDirectory, safeName);
@@ -15187,23 +15247,24 @@ function shareCliError(error) {
15187
15247
  const profileName = "profileName" in failure ? failure.profileName : void 0;
15188
15248
  const localKey = "localKey" in failure && failure.localKey === true;
15189
15249
  const profileHint = profileName === void 0 ? "" : `--profile ${profileName} `;
15190
- const loginHint = `tc ${profileHint}auth login${localKey ? " --method local" : ""}`;
15250
+ const loginHint = localKey ? `\`tc ${profileHint}auth login --method local\`` : `\`tc ${profileHint}auth login --device --manifest builtin:share-publishing\` (or \`tc ${profileHint}enable share\`)`;
15191
15251
  if (failure.kind === "owner-space-unresolved") {
15192
- return new CLIError("AUTH_REQUIRED", `a valid signed TinyCloud session is required; run \`${loginHint}\``, 3);
15252
+ return new CLIError("AUTH_REQUIRED", `a valid signed TinyCloud session is required; run ${loginHint}`, 3);
15193
15253
  }
15194
15254
  if (failure.kind === "scope-denied") {
15195
15255
  const requiredAction = failure.requiredAction === void 0 ? "" : ` (${failure.requiredAction})`;
15196
- return new CLIError("PERMISSION_DENIED", `the session lacks ${failure.capability} authority${requiredAction}; renew it with \`${loginHint}\` using the required capability`, 5);
15256
+ const renew = localKey ? `renew it with ${loginHint} using the required capability` : `request the builtin:share-publishing scope with ${loginHint}`;
15257
+ return new CLIError("PERMISSION_DENIED", `the session lacks ${failure.capability} authority${requiredAction}; ${renew}`, 5);
15197
15258
  }
15198
15259
  if (failure.kind === "lifetime-exceeds-session") {
15199
15260
  const expiresAt = failure.sessionExpiresAt.toISOString();
15200
15261
  if (failure.reason === "session-too-close") {
15201
- return new CLIError("AUTH_REQUIRED", `the signed session expires too soon (${expiresAt}); log in again with \`${loginHint}\``, 3);
15262
+ return new CLIError("AUTH_REQUIRED", `the signed session expires too soon (${expiresAt}); log in again with ${loginHint}`, 3);
15202
15263
  }
15203
15264
  if (failure.reason === "below-minimum") {
15204
15265
  return new CLIError("SESSION_LIFETIME_EXCEEDED", "share expiry must be at least 60 seconds from now; use a longer --expires value", 2);
15205
15266
  }
15206
- return new CLIError("SESSION_LIFETIME_EXCEEDED", `requested share lifetime exceeds session expiry ${expiresAt}; use a shorter --expires value or renew the session with \`${loginHint}\``, 2);
15267
+ return new CLIError("SESSION_LIFETIME_EXCEEDED", `requested share lifetime exceeds session expiry ${expiresAt}; use a shorter --expires value or renew the session with ${loginHint}`, 2);
15207
15268
  }
15208
15269
  if (failure.kind === "origin-mismatch") {
15209
15270
  return new CLIError("ORIGIN_MISMATCH", "share origin does not match the configured service", 2);