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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  tinycloudConfigPath,
15
15
  tinycloudHomePath
16
16
  } from "@tinycloud/operations/state";
17
- var CONFIG_DIR, PROFILES_DIR, CONFIG_FILE, DEFAULT_HOST, DEFAULT_PROFILE, ExitCode;
17
+ var CONFIG_DIR, PROFILES_DIR, CONFIG_FILE, DEFAULT_HOST, DEFAULT_PROFILE, PROFILE_COMMIT_LOCK_TIMEOUT_MS, ExitCode;
18
18
  var init_constants = __esm({
19
19
  "src/config/constants.ts"() {
20
20
  "use strict";
@@ -23,6 +23,7 @@ var init_constants = __esm({
23
23
  CONFIG_FILE = tinycloudConfigPath();
24
24
  DEFAULT_HOST = "https://tee.node.tinycloud.xyz";
25
25
  DEFAULT_PROFILE = "default";
26
+ PROFILE_COMMIT_LOCK_TIMEOUT_MS = 45e3;
26
27
  ExitCode = {
27
28
  SUCCESS: 0,
28
29
  ERROR: 1,
@@ -281,9 +282,6 @@ async function fileExists(filePath) {
281
282
  async function ensureDir(dirPath) {
282
283
  await mkdir(dirPath, { recursive: true, mode: PRIVATE_DIR_MODE });
283
284
  }
284
- async function removeDir(dirPath) {
285
- await rm(dirPath, { recursive: true, force: true });
286
- }
287
285
  async function listDirs(dirPath) {
288
286
  try {
289
287
  const entries = await readdir(dirPath, { withFileTypes: true });
@@ -538,9 +536,10 @@ var init_host = __esm({
538
536
  });
539
537
 
540
538
  // src/config/profiles.ts
541
- import { chmod, rm as rm2 } from "fs/promises";
539
+ import { chmod, lstat, readdir as readdir2, rm as rm2, rmdir } from "fs/promises";
542
540
  import { join as join3 } from "path";
543
541
  import {
542
+ profilePath,
544
543
  readSession,
545
544
  removeSession,
546
545
  withProfileLock,
@@ -616,8 +615,8 @@ var init_profiles = __esm({
616
615
  * Throws CLIError if the profile doesn't exist.
617
616
  */
618
617
  static async getProfile(name) {
619
- const profilePath = join3(PROFILES_DIR, name, "profile.json");
620
- const profile = await readJson(profilePath);
618
+ const profilePath2 = join3(PROFILES_DIR, name, "profile.json");
619
+ const profile = await readJson(profilePath2);
621
620
  if (!profile) {
622
621
  throw new CLIError(
623
622
  "PROFILE_NOT_FOUND",
@@ -652,10 +651,27 @@ var init_profiles = __esm({
652
651
  return listDirs(PROFILES_DIR);
653
652
  }
654
653
  /**
655
- * Deletes a profile directory.
656
- * Throws if trying to delete the current default profile.
654
+ * Deletes a profile. Its key, session, settings, stores and cache are
655
+ * removed while holding the profile lock, so another writer's critical
656
+ * section never sees them vanish midway: session and key first, settings
657
+ * last, so a crash midway never leaves a session or key without its
658
+ * profile. `.lock` itself is left to the lock's release; the then-empty
659
+ * directory is removed afterwards unless another writer took the lock (or
660
+ * wrote) meanwhile. A profile directory that is a symlink is unlinked, its
661
+ * target left alone.
662
+ * Throws if the name is not one path segment or names the default profile.
657
663
  */
658
664
  static async deleteProfile(name) {
665
+ let profileDir;
666
+ try {
667
+ profileDir = profilePath(name);
668
+ } catch {
669
+ throw new CLIError(
670
+ "INVALID_PROFILE_NAME",
671
+ `Invalid profile name "${name}": a profile name is one path segment (no "/", "\\", "." or "..").`,
672
+ ExitCode.USAGE_ERROR
673
+ );
674
+ }
659
675
  const config = await _ProfileManager.getConfig();
660
676
  if (config.defaultProfile === name) {
661
677
  throw new CLIError(
@@ -663,8 +679,24 @@ var init_profiles = __esm({
663
679
  `Cannot delete the default profile "${name}". Change the default first with \`tc profile default <other>\`.`
664
680
  );
665
681
  }
666
- const profileDir = join3(PROFILES_DIR, name);
667
- await removeDir(profileDir);
682
+ const isLink = await lstat(profileDir).then((stats) => stats.isSymbolicLink(), (error) => {
683
+ if (error.code === "ENOENT") return false;
684
+ throw error;
685
+ });
686
+ if (isLink) {
687
+ await rm2(profileDir, { force: true });
688
+ return;
689
+ }
690
+ await _ProfileManager.withLock(name, async () => {
691
+ for (const file of ["session.json", "key.json"]) await rm2(join3(profileDir, file), { force: true });
692
+ for (const entry of await readdir2(profileDir)) {
693
+ if (entry !== ".lock" && entry !== "profile.json") await rm2(join3(profileDir, entry), { recursive: true, force: true });
694
+ }
695
+ await rm2(join3(profileDir, "profile.json"), { force: true });
696
+ });
697
+ await rmdir(profileDir).catch((error) => {
698
+ if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY" && error.code !== "EEXIST") throw error;
699
+ });
668
700
  }
669
701
  // ── Key management ──────────────────────────────────────────────────
670
702
  /**
@@ -5758,39 +5790,89 @@ async function createSDKInstance(ctx, options) {
5758
5790
  return node;
5759
5791
  }
5760
5792
  async function bootstrapDelegatedSession(ctx, delegation) {
5761
- const profile = await ProfileManager.getProfile(ctx.profile);
5762
- if (resolveProfilePosture(profile) !== "delegate-session") {
5763
- throw new CLIError(
5764
- "AUTH_REQUIRED",
5765
- `Profile "${ctx.profile}" is not a delegate-session profile.`,
5766
- ExitCode.AUTH_REQUIRED
5767
- );
5768
- }
5769
- const sessionDid = profile.sessionDid ?? profile.did;
5770
- if (delegation.delegateDID.split("#", 1)[0] !== sessionDid.split("#", 1)[0]) {
5771
- throw new CLIError(
5772
- "DELEGATION_AUDIENCE_MISMATCH",
5773
- `Delegation targets ${delegation.delegateDID}, but profile "${ctx.profile}" uses ${sessionDid}.`,
5774
- ExitCode.PERMISSION_DENIED
5775
- );
5776
- }
5777
- const key = await ProfileManager.getKey(ctx.profile);
5778
- const jwk = signerJwkForProfile(ctx.profile, void 0, key);
5779
- await ProfileManager.withLock(ctx.profile, async () => {
5780
- await ProfileManager.setSession(ctx.profile, {
5793
+ const written = await ProfileManager.withLock(ctx.profile, async () => {
5794
+ const previousProfile = await ProfileManager.getProfile(ctx.profile);
5795
+ if (resolveProfilePosture(previousProfile) !== "delegate-session") {
5796
+ throw new CLIError(
5797
+ "AUTH_REQUIRED",
5798
+ `Profile "${ctx.profile}" is not a delegate-session profile.`,
5799
+ ExitCode.AUTH_REQUIRED
5800
+ );
5801
+ }
5802
+ const sessionDid = previousProfile.sessionDid ?? previousProfile.did;
5803
+ if (delegation.delegateDID.split("#", 1)[0] !== sessionDid.split("#", 1)[0]) {
5804
+ throw new CLIError(
5805
+ "DELEGATION_AUDIENCE_MISMATCH",
5806
+ `Delegation targets ${delegation.delegateDID}, but profile "${ctx.profile}" uses ${sessionDid}.`,
5807
+ ExitCode.PERMISSION_DENIED
5808
+ );
5809
+ }
5810
+ if (await ProfileManager.getSession(ctx.profile) !== null) {
5811
+ throw new CLIError(
5812
+ "PROFILE_CHANGED_DURING_IMPORT",
5813
+ `Profile "${ctx.profile}" gained a session (another login or import) after this import checked it. Nothing was saved; run the import again.`,
5814
+ ExitCode.ERROR
5815
+ );
5816
+ }
5817
+ const jwk = signerJwkForProfile(ctx.profile, void 0, await ProfileManager.getKey(ctx.profile));
5818
+ const session = {
5781
5819
  delegationHeader: delegation.delegationHeader,
5782
5820
  delegationCid: delegation.cid,
5783
5821
  spaceId: delegation.spaceId,
5784
5822
  jwk,
5785
5823
  verificationMethod: sessionDid
5786
- });
5787
- await ProfileManager.updateProfile(ctx.profile, (current) => ({
5788
- ...current,
5789
- sessionDid,
5790
- spaceId: delegation.spaceId
5791
- }));
5824
+ };
5825
+ const profile = { ...previousProfile, sessionDid, spaceId: delegation.spaceId };
5826
+ try {
5827
+ await ProfileManager.setSession(ctx.profile, session);
5828
+ await ProfileManager.setProfile(ctx.profile, profile);
5829
+ } catch (error) {
5830
+ throw annotate(error, await restoreBeforeBootstrap(ctx.profile, previousProfile));
5831
+ }
5832
+ return { previousProfile, profile, session };
5792
5833
  });
5793
- return createSDKInstance(ctx);
5834
+ const abandon = async (cause) => {
5835
+ const note = await ProfileManager.withLock(ctx.profile, async () => {
5836
+ const profile = await ProfileManager.getProfile(ctx.profile).catch((error) => {
5837
+ if (error instanceof CLIError && error.code === "PROFILE_NOT_FOUND") return null;
5838
+ throw error;
5839
+ });
5840
+ const session = await ProfileManager.getSession(ctx.profile);
5841
+ if (JSON.stringify(profile) !== JSON.stringify(written.profile) || JSON.stringify(session) !== JSON.stringify(written.session)) {
5842
+ 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.`;
5843
+ }
5844
+ return restoreBeforeBootstrap(ctx.profile, written.previousProfile);
5845
+ }, { timeoutMs: PROFILE_COMMIT_LOCK_TIMEOUT_MS }).catch((error) => (
5846
+ // The lock or a read failed: the import's error stays the one reported.
5847
+ `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\`.`
5848
+ ));
5849
+ throw annotate(cause, note);
5850
+ };
5851
+ let node;
5852
+ try {
5853
+ node = await createSDKInstance(ctx);
5854
+ } catch (error) {
5855
+ return abandon(error);
5856
+ }
5857
+ return { node, abandon };
5858
+ }
5859
+ async function restoreBeforeBootstrap(profileName, previousProfile) {
5860
+ const failures = [];
5861
+ for (const write of [
5862
+ () => ProfileManager.clearSession(profileName),
5863
+ () => ProfileManager.setProfile(profileName, previousProfile)
5864
+ ]) {
5865
+ await write().catch((error) => {
5866
+ failures.push(error instanceof Error ? error.message : String(error));
5867
+ });
5868
+ }
5869
+ if (failures.length === 0) return void 0;
5870
+ return `Rolling back the provisional session of profile "${profileName}" failed too (${failures.join("; ")}); check \`tc --profile ${profileName} context\`.`;
5871
+ }
5872
+ function annotate(error, note) {
5873
+ if (note === void 0) return error;
5874
+ const cause = wrapError(error);
5875
+ return new CLIError(cause.code, `${cause.message} ${note}`, cause.exitCode, cause.metadata);
5794
5876
  }
5795
5877
  async function ensureAuthenticated(ctx, options) {
5796
5878
  if (options?.privateKey) {
@@ -15143,7 +15225,7 @@ function receiveJson(result, path) {
15143
15225
 
15144
15226
  // src/share/io.ts
15145
15227
  import { constants } from "fs";
15146
- 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";
15228
+ import { lstat as lstat2, 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";
15147
15229
  import { randomBytes as randomBytes2 } from "crypto";
15148
15230
  import { basename as basename2, join as join4, resolve, sep } from "path";
15149
15231
  init_errors();
@@ -15201,7 +15283,7 @@ async function assertDirectory(path) {
15201
15283
  for (const segment of segments) {
15202
15284
  current = current === sep ? join4(current, segment) : join4(current, segment);
15203
15285
  try {
15204
- const info = await lstat(current);
15286
+ const info = await lstat2(current);
15205
15287
  if (info.isSymbolicLink()) {
15206
15288
  const canonical = await realpath(current);
15207
15289
  if (current !== "/tmp" && current !== "/var") throw new Error("OUTPUT_EXISTS");
@@ -15210,7 +15292,7 @@ async function assertDirectory(path) {
15210
15292
  } catch (error) {
15211
15293
  if (error.code !== "ENOENT") throw error;
15212
15294
  await mkdir2(current, { mode: 448 });
15213
- const created = await lstat(current);
15295
+ const created = await lstat2(current);
15214
15296
  if (created.isSymbolicLink() || !created.isDirectory()) throw new Error("OUTPUT_EXISTS");
15215
15297
  }
15216
15298
  }
@@ -15229,7 +15311,7 @@ async function writeShareOutput(directory, filename, bytes, force) {
15229
15311
  };
15230
15312
  await assertStableDirectory();
15231
15313
  const stagingDirectory = await mkdtemp(join4(stableDirectory, ".tinycloud-share-stage-"));
15232
- const stagingInfo = await lstat(stagingDirectory);
15314
+ const stagingInfo = await lstat2(stagingDirectory);
15233
15315
  if (!stagingInfo.isDirectory() || (stagingInfo.mode & 511) !== 448) throw new Error("OUTPUT_EXISTS");
15234
15316
  const stagingPath = join4(stagingDirectory, `.tinycloud-share-${randomBytes2(16).toString("hex")}.tmp`);
15235
15317
  let temporaryPath;
@@ -15237,7 +15319,7 @@ async function writeShareOutput(directory, filename, bytes, force) {
15237
15319
  try {
15238
15320
  await assertStableDirectory();
15239
15321
  try {
15240
- const existing = await lstat(outputPath);
15322
+ const existing = await lstat2(outputPath);
15241
15323
  if (existing.isSymbolicLink()) throw new Error("UNSAFE_FILENAME");
15242
15324
  } catch (error) {
15243
15325
  if (error.code !== "ENOENT") throw error;