@tinycloud/cli 1.0.1-beta.0 → 1.0.1-beta.1

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.
@@ -37,7 +37,7 @@ import {
37
37
  tinycloudConfigPath,
38
38
  tinycloudHomePath
39
39
  } from "@tinycloud/operations/state";
40
- var CONFIG_DIR, PROFILES_DIR, CONFIG_FILE, DEFAULT_HOST, DEFAULT_OPENKEY_HOST, DEFAULT_OPENKEY_DEVICE_API_HOST, DEFAULT_SHARE_ORIGIN, DEFAULT_PROFILE, DEFAULT_CHAIN_ID, ExitCode;
40
+ var CONFIG_DIR, PROFILES_DIR, CONFIG_FILE, DEFAULT_HOST, DEFAULT_OPENKEY_HOST, DEFAULT_OPENKEY_DEVICE_API_HOST, DEFAULT_SHARE_ORIGIN, DEFAULT_PROFILE, DEFAULT_CHAIN_ID, PROFILE_COMMIT_LOCK_TIMEOUT_MS, ExitCode;
41
41
  var init_constants = __esm({
42
42
  "src/config/constants.ts"() {
43
43
  "use strict";
@@ -50,6 +50,7 @@ var init_constants = __esm({
50
50
  DEFAULT_SHARE_ORIGIN = "https://share.tinycloud.xyz";
51
51
  DEFAULT_PROFILE = "default";
52
52
  DEFAULT_CHAIN_ID = 1;
53
+ PROFILE_COMMIT_LOCK_TIMEOUT_MS = 45e3;
53
54
  ExitCode = {
54
55
  SUCCESS: 0,
55
56
  ERROR: 1,
@@ -104,9 +105,6 @@ async function fileExists(filePath) {
104
105
  async function ensureDir(dirPath) {
105
106
  await mkdir(dirPath, { recursive: true, mode: PRIVATE_DIR_MODE });
106
107
  }
107
- async function removeDir(dirPath) {
108
- await rm(dirPath, { recursive: true, force: true });
109
- }
110
108
  async function listDirs(dirPath) {
111
109
  try {
112
110
  const entries = await readdir(dirPath, { withFileTypes: true });
@@ -641,9 +639,10 @@ var init_host = __esm({
641
639
  });
642
640
 
643
641
  // src/config/profiles.ts
644
- import { chmod, rm as rm2 } from "fs/promises";
642
+ import { chmod, lstat, readdir as readdir2, rm as rm2, rmdir } from "fs/promises";
645
643
  import { join as join3 } from "path";
646
644
  import {
645
+ profilePath,
647
646
  readSession,
648
647
  removeSession,
649
648
  withProfileLock,
@@ -719,8 +718,8 @@ var init_profiles = __esm({
719
718
  * Throws CLIError if the profile doesn't exist.
720
719
  */
721
720
  static async getProfile(name) {
722
- const profilePath = join3(PROFILES_DIR, name, "profile.json");
723
- const profile = await readJson(profilePath);
721
+ const profilePath2 = join3(PROFILES_DIR, name, "profile.json");
722
+ const profile = await readJson(profilePath2);
724
723
  if (!profile) {
725
724
  throw new CLIError(
726
725
  "PROFILE_NOT_FOUND",
@@ -755,10 +754,27 @@ var init_profiles = __esm({
755
754
  return listDirs(PROFILES_DIR);
756
755
  }
757
756
  /**
758
- * Deletes a profile directory.
759
- * Throws if trying to delete the current default profile.
757
+ * Deletes a profile. Its key, session, settings, stores and cache are
758
+ * removed while holding the profile lock, so another writer's critical
759
+ * section never sees them vanish midway: session and key first, settings
760
+ * last, so a crash midway never leaves a session or key without its
761
+ * profile. `.lock` itself is left to the lock's release; the then-empty
762
+ * directory is removed afterwards unless another writer took the lock (or
763
+ * wrote) meanwhile. A profile directory that is a symlink is unlinked, its
764
+ * target left alone.
765
+ * Throws if the name is not one path segment or names the default profile.
760
766
  */
761
767
  static async deleteProfile(name) {
768
+ let profileDir;
769
+ try {
770
+ profileDir = profilePath(name);
771
+ } catch {
772
+ throw new CLIError(
773
+ "INVALID_PROFILE_NAME",
774
+ `Invalid profile name "${name}": a profile name is one path segment (no "/", "\\", "." or "..").`,
775
+ ExitCode.USAGE_ERROR
776
+ );
777
+ }
762
778
  const config = await _ProfileManager.getConfig();
763
779
  if (config.defaultProfile === name) {
764
780
  throw new CLIError(
@@ -766,8 +782,24 @@ var init_profiles = __esm({
766
782
  `Cannot delete the default profile "${name}". Change the default first with \`tc profile default <other>\`.`
767
783
  );
768
784
  }
769
- const profileDir = join3(PROFILES_DIR, name);
770
- await removeDir(profileDir);
785
+ const isLink = await lstat(profileDir).then((stats) => stats.isSymbolicLink(), (error) => {
786
+ if (error.code === "ENOENT") return false;
787
+ throw error;
788
+ });
789
+ if (isLink) {
790
+ await rm2(profileDir, { force: true });
791
+ return;
792
+ }
793
+ await _ProfileManager.withLock(name, async () => {
794
+ for (const file of ["session.json", "key.json"]) await rm2(join3(profileDir, file), { force: true });
795
+ for (const entry of await readdir2(profileDir)) {
796
+ if (entry !== ".lock" && entry !== "profile.json") await rm2(join3(profileDir, entry), { recursive: true, force: true });
797
+ }
798
+ await rm2(join3(profileDir, "profile.json"), { force: true });
799
+ });
800
+ await rmdir(profileDir).catch((error) => {
801
+ if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY" && error.code !== "EEXIST") throw error;
802
+ });
771
803
  }
772
804
  // ── Key management ──────────────────────────────────────────────────
773
805
  /**
@@ -13493,39 +13525,89 @@ async function createSDKInstance(ctx, options) {
13493
13525
  return node;
13494
13526
  }
13495
13527
  async function bootstrapDelegatedSession(ctx, delegation) {
13496
- const profile = await ProfileManager.getProfile(ctx.profile);
13497
- if (resolveProfilePosture(profile) !== "delegate-session") {
13498
- throw new CLIError(
13499
- "AUTH_REQUIRED",
13500
- `Profile "${ctx.profile}" is not a delegate-session profile.`,
13501
- ExitCode.AUTH_REQUIRED
13502
- );
13503
- }
13504
- const sessionDid = profile.sessionDid ?? profile.did;
13505
- if (delegation.delegateDID.split("#", 1)[0] !== sessionDid.split("#", 1)[0]) {
13506
- throw new CLIError(
13507
- "DELEGATION_AUDIENCE_MISMATCH",
13508
- `Delegation targets ${delegation.delegateDID}, but profile "${ctx.profile}" uses ${sessionDid}.`,
13509
- ExitCode.PERMISSION_DENIED
13510
- );
13511
- }
13512
- const key = await ProfileManager.getKey(ctx.profile);
13513
- const jwk = signerJwkForProfile(ctx.profile, void 0, key);
13514
- await ProfileManager.withLock(ctx.profile, async () => {
13515
- await ProfileManager.setSession(ctx.profile, {
13528
+ const written = await ProfileManager.withLock(ctx.profile, async () => {
13529
+ const previousProfile = await ProfileManager.getProfile(ctx.profile);
13530
+ if (resolveProfilePosture(previousProfile) !== "delegate-session") {
13531
+ throw new CLIError(
13532
+ "AUTH_REQUIRED",
13533
+ `Profile "${ctx.profile}" is not a delegate-session profile.`,
13534
+ ExitCode.AUTH_REQUIRED
13535
+ );
13536
+ }
13537
+ const sessionDid = previousProfile.sessionDid ?? previousProfile.did;
13538
+ if (delegation.delegateDID.split("#", 1)[0] !== sessionDid.split("#", 1)[0]) {
13539
+ throw new CLIError(
13540
+ "DELEGATION_AUDIENCE_MISMATCH",
13541
+ `Delegation targets ${delegation.delegateDID}, but profile "${ctx.profile}" uses ${sessionDid}.`,
13542
+ ExitCode.PERMISSION_DENIED
13543
+ );
13544
+ }
13545
+ if (await ProfileManager.getSession(ctx.profile) !== null) {
13546
+ throw new CLIError(
13547
+ "PROFILE_CHANGED_DURING_IMPORT",
13548
+ `Profile "${ctx.profile}" gained a session (another login or import) after this import checked it. Nothing was saved; run the import again.`,
13549
+ ExitCode.ERROR
13550
+ );
13551
+ }
13552
+ const jwk = signerJwkForProfile(ctx.profile, void 0, await ProfileManager.getKey(ctx.profile));
13553
+ const session = {
13516
13554
  delegationHeader: delegation.delegationHeader,
13517
13555
  delegationCid: delegation.cid,
13518
13556
  spaceId: delegation.spaceId,
13519
13557
  jwk,
13520
13558
  verificationMethod: sessionDid
13521
- });
13522
- await ProfileManager.updateProfile(ctx.profile, (current) => ({
13523
- ...current,
13524
- sessionDid,
13525
- spaceId: delegation.spaceId
13526
- }));
13559
+ };
13560
+ const profile = { ...previousProfile, sessionDid, spaceId: delegation.spaceId };
13561
+ try {
13562
+ await ProfileManager.setSession(ctx.profile, session);
13563
+ await ProfileManager.setProfile(ctx.profile, profile);
13564
+ } catch (error) {
13565
+ throw annotate(error, await restoreBeforeBootstrap(ctx.profile, previousProfile));
13566
+ }
13567
+ return { previousProfile, profile, session };
13527
13568
  });
13528
- return createSDKInstance(ctx);
13569
+ const abandon = async (cause) => {
13570
+ const note = await ProfileManager.withLock(ctx.profile, async () => {
13571
+ const profile = await ProfileManager.getProfile(ctx.profile).catch((error) => {
13572
+ if (error instanceof CLIError && error.code === "PROFILE_NOT_FOUND") return null;
13573
+ throw error;
13574
+ });
13575
+ const session = await ProfileManager.getSession(ctx.profile);
13576
+ if (JSON.stringify(profile) !== JSON.stringify(written.profile) || JSON.stringify(session) !== JSON.stringify(written.session)) {
13577
+ return `Profile "${ctx.profile}" changed while the import was pending (another login, logout or profile update), so its newer state was kept and the provisional session was not rolled back.`;
13578
+ }
13579
+ return restoreBeforeBootstrap(ctx.profile, written.previousProfile);
13580
+ }, { timeoutMs: PROFILE_COMMIT_LOCK_TIMEOUT_MS }).catch((error) => (
13581
+ // The lock or a read failed: the import's error stays the one reported.
13582
+ `Rolling back the provisional session of profile "${ctx.profile}" could not run (${error instanceof Error ? error.message : String(error)}); check \`tc --profile ${ctx.profile} context\`.`
13583
+ ));
13584
+ throw annotate(cause, note);
13585
+ };
13586
+ let node;
13587
+ try {
13588
+ node = await createSDKInstance(ctx);
13589
+ } catch (error) {
13590
+ return abandon(error);
13591
+ }
13592
+ return { node, abandon };
13593
+ }
13594
+ async function restoreBeforeBootstrap(profileName, previousProfile) {
13595
+ const failures = [];
13596
+ for (const write of [
13597
+ () => ProfileManager.clearSession(profileName),
13598
+ () => ProfileManager.setProfile(profileName, previousProfile)
13599
+ ]) {
13600
+ await write().catch((error) => {
13601
+ failures.push(error instanceof Error ? error.message : String(error));
13602
+ });
13603
+ }
13604
+ if (failures.length === 0) return void 0;
13605
+ return `Rolling back the provisional session of profile "${profileName}" failed too (${failures.join("; ")}); check \`tc --profile ${profileName} context\`.`;
13606
+ }
13607
+ function annotate(error, note) {
13608
+ if (note === void 0) return error;
13609
+ const cause = wrapError(error);
13610
+ return new CLIError(cause.code, `${cause.message} ${note}`, cause.exitCode, cause.metadata);
13529
13611
  }
13530
13612
  async function ensureAuthenticated(ctx, options) {
13531
13613
  if (options?.privateKey) {
@@ -14590,7 +14672,6 @@ function assertSessionReplaceable(profileName, snapshot, ownerDid, scope, newExp
14590
14672
  ExitCode.USAGE_ERROR
14591
14673
  );
14592
14674
  }
14593
- var COMMIT_LOCK_TIMEOUT_MS = 45e3;
14594
14675
  async function restore(profileName, state) {
14595
14676
  const writes = [
14596
14677
  () => state.key === null ? ProfileManager.removeKey(profileName) : ProfileManager.setKey(profileName, state.key),
@@ -14616,7 +14697,7 @@ async function commitLogin(profileName, snapshot, commit) {
14616
14697
  if (canonicalJson(current.profile) !== canonicalJson(snapshot.profile) || canonicalJson(current.key) !== canonicalJson(snapshot.key) || canonicalJson(current.session) !== canonicalJson(snapshot.session)) {
14617
14698
  throw new CLIError(
14618
14699
  "PROFILE_CHANGED_DURING_LOGIN",
14619
- `Profile "${profileName}" changed while waiting for approval (another login, key rotation or logout). Nothing was saved; check \`tc --profile ${profileName} context\` and run the login again if it is still needed.`,
14700
+ `Profile "${profileName}" changed while this login was in progress (another login, key rotation or logout). Nothing was saved; check \`tc --profile ${profileName} context\` and run the login again if it is still needed.`,
14620
14701
  ExitCode.ERROR
14621
14702
  );
14622
14703
  }
@@ -14637,11 +14718,11 @@ async function commitLogin(profileName, snapshot, commit) {
14637
14718
  ExitCode.ERROR
14638
14719
  );
14639
14720
  }
14640
- }, { timeoutMs: COMMIT_LOCK_TIMEOUT_MS }).catch((error) => {
14721
+ }, { timeoutMs: PROFILE_COMMIT_LOCK_TIMEOUT_MS }).catch((error) => {
14641
14722
  if (!(error instanceof ProfileLockTimeoutError2)) throw error;
14642
14723
  throw new CLIError(
14643
14724
  "PROFILE_LOCK_TIMEOUT",
14644
- `Another tc process kept profile "${profileName}" locked for ${COMMIT_LOCK_TIMEOUT_MS / 1e3} s, so the approved login was not saved. Wait for it to finish (a crashed process's lock is reclaimed after 30 s) and run the login again.`,
14725
+ `Another tc process kept profile "${profileName}" locked for ${PROFILE_COMMIT_LOCK_TIMEOUT_MS / 1e3} s, so the approved login was not saved. Wait for it to finish (a crashed process's lock is reclaimed after 30 s) and run the login again.`,
14645
14726
  ExitCode.ERROR
14646
14727
  );
14647
14728
  });
@@ -15432,7 +15513,7 @@ function registerAuthCommand(program) {
15432
15513
  const profile = await ProfileManager.getProfile(ctx.profile);
15433
15514
  const session = await ProfileManager.getSession(ctx.profile);
15434
15515
  if (session || resolveProfilePosture(profile) !== "delegate-session") throw error;
15435
- node = await bootstrapDelegatedSession(ctx, imported.delegation);
15516
+ node = (await bootstrapDelegatedSession(ctx, imported.delegation)).node;
15436
15517
  }
15437
15518
  await appendAdditionalDelegation(ctx.profile, storedAdditionalDelegation(
15438
15519
  imported.delegation,
@@ -15757,15 +15838,11 @@ async function importRequestBoundDelegationWithBootstrap(ctx, artifact) {
15757
15838
  }
15758
15839
  const candidate = artifact;
15759
15840
  const delegation = normalizePortableDelegation(candidate.delegation);
15841
+ const bootstrap = await bootstrapDelegatedSession(ctx, delegation);
15760
15842
  try {
15761
- await bootstrapDelegatedSession(ctx, delegation);
15762
15843
  await importRequestBoundDelegation(ctx, artifact);
15763
15844
  } catch (error) {
15764
- await ProfileManager.withLock(ctx.profile, async () => {
15765
- await ProfileManager.clearSession(ctx.profile);
15766
- await ProfileManager.setProfile(ctx.profile, profile);
15767
- });
15768
- throw error;
15845
+ await bootstrap.abandon(error);
15769
15846
  }
15770
15847
  }
15771
15848
  async function importRequestBoundDelegation(ctx, artifact) {
@@ -16079,7 +16156,6 @@ async function rotateAuthKey(profileName, host, options = {}) {
16079
16156
  ExitCode.AUTH_REQUIRED
16080
16157
  );
16081
16158
  }
16082
- await ProfileManager.clearSession(profileName);
16083
16159
  const result2 = await handleLocalAuth(profileName, host, {
16084
16160
  emitOutput: false,
16085
16161
  forceSessionKey: true
@@ -17643,7 +17719,7 @@ init_errors();
17643
17719
  init_constants();
17644
17720
  init_storage();
17645
17721
  import { randomUUID as randomUUID2 } from "crypto";
17646
- import { lstat, open as open2, readFile as readFile8, rename as rename2, rm as rm3 } from "fs/promises";
17722
+ import { lstat as lstat2, open as open2, readFile as readFile8, rename as rename2, rm as rm3 } from "fs/promises";
17647
17723
  import { basename as basename2, dirname as dirname3, join as join5 } from "path";
17648
17724
  import { homedir } from "os";
17649
17725
  import { invokeOperation as invokeOperation2 } from "@tinycloud/operations";
@@ -17817,7 +17893,7 @@ function scopedSecretLoginHint(profileName) {
17817
17893
  }
17818
17894
  async function validateSecretOutput(path) {
17819
17895
  try {
17820
- const destination = await lstat(path);
17896
+ const destination = await lstat2(path);
17821
17897
  if (!destination.isFile()) {
17822
17898
  throw new CLIError("INVALID_ARGUMENT", `Secret output "${path}" must be a regular file, not a symlink, directory, or device.`, ExitCode.USAGE_ERROR);
17823
17899
  }
@@ -17825,7 +17901,7 @@ async function validateSecretOutput(path) {
17825
17901
  if (!isMissingFileError(error)) throw error;
17826
17902
  }
17827
17903
  try {
17828
- const parent = await lstat(dirname3(path));
17904
+ const parent = await lstat2(dirname3(path));
17829
17905
  if (!parent.isDirectory()) {
17830
17906
  throw new CLIError("INVALID_ARGUMENT", `Secret output "${path}" requires an existing directory parent.`, ExitCode.USAGE_ERROR);
17831
17907
  }
@@ -27477,7 +27553,7 @@ function receiveJson(result, path) {
27477
27553
 
27478
27554
  // src/share/io.ts
27479
27555
  import { constants } from "fs";
27480
- import { lstat as lstat2, mkdir as mkdir3, mkdtemp, open as open4, readFile as readFile9, realpath, stat as stat2, link, rename as rename3, rm as rm4, unlink } from "fs/promises";
27556
+ import { lstat as lstat3, mkdir as mkdir3, mkdtemp, open as open4, readFile as readFile9, realpath, stat as stat2, link, rename as rename3, rm as rm4, unlink } from "fs/promises";
27481
27557
  import { randomBytes as randomBytes4 } from "crypto";
27482
27558
  import { basename as basename3, join as join6, resolve as resolve2, sep } from "path";
27483
27559
  init_errors();
@@ -27535,7 +27611,7 @@ async function assertDirectory(path) {
27535
27611
  for (const segment of segments) {
27536
27612
  current = current === sep ? join6(current, segment) : join6(current, segment);
27537
27613
  try {
27538
- const info = await lstat2(current);
27614
+ const info = await lstat3(current);
27539
27615
  if (info.isSymbolicLink()) {
27540
27616
  const canonical = await realpath(current);
27541
27617
  if (current !== "/tmp" && current !== "/var") throw new Error("OUTPUT_EXISTS");
@@ -27544,7 +27620,7 @@ async function assertDirectory(path) {
27544
27620
  } catch (error) {
27545
27621
  if (error.code !== "ENOENT") throw error;
27546
27622
  await mkdir3(current, { mode: 448 });
27547
- const created = await lstat2(current);
27623
+ const created = await lstat3(current);
27548
27624
  if (created.isSymbolicLink() || !created.isDirectory()) throw new Error("OUTPUT_EXISTS");
27549
27625
  }
27550
27626
  }
@@ -27563,7 +27639,7 @@ async function writeShareOutput(directory, filename, bytes, force) {
27563
27639
  };
27564
27640
  await assertStableDirectory();
27565
27641
  const stagingDirectory = await mkdtemp(join6(stableDirectory, ".tinycloud-share-stage-"));
27566
- const stagingInfo = await lstat2(stagingDirectory);
27642
+ const stagingInfo = await lstat3(stagingDirectory);
27567
27643
  if (!stagingInfo.isDirectory() || (stagingInfo.mode & 511) !== 448) throw new Error("OUTPUT_EXISTS");
27568
27644
  const stagingPath = join6(stagingDirectory, `.tinycloud-share-${randomBytes4(16).toString("hex")}.tmp`);
27569
27645
  let temporaryPath;
@@ -27571,7 +27647,7 @@ async function writeShareOutput(directory, filename, bytes, force) {
27571
27647
  try {
27572
27648
  await assertStableDirectory();
27573
27649
  try {
27574
- const existing = await lstat2(outputPath);
27650
+ const existing = await lstat3(outputPath);
27575
27651
  if (existing.isSymbolicLink()) throw new Error("UNSAFE_FILENAME");
27576
27652
  } catch (error) {
27577
27653
  if (error.code !== "ENOENT") throw error;