@tinycloud/cli 1.0.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.
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) {
@@ -12900,11 +12982,15 @@ var contentMetadataSchema = external_exports.object({
12900
12982
  /** Encrypted presentation discriminator. The fixed entry point is index.html. */
12901
12983
  artifact: external_exports.literal("html").optional()
12902
12984
  }).strict();
12985
+ var deliveryEmailSchema = external_exports.string().email();
12986
+ function isEnvelopeDeliveryEmail(value) {
12987
+ return deliveryEmailSchema.safeParse(value).success;
12988
+ }
12903
12989
  var unsignedShareEnvelopeV2BaseSchema = external_exports.object({
12904
12990
  version: external_exports.literal(2),
12905
12991
  shareId: external_exports.string().min(1),
12906
12992
  recipientMatcher: recipientMatcherSchema,
12907
- deliveryEmail: external_exports.string().email().optional(),
12993
+ deliveryEmail: deliveryEmailSchema.optional(),
12908
12994
  actions: external_exports.array(shareActionSchema).min(1).max(3),
12909
12995
  resource: resourceSelectorSchema,
12910
12996
  target: v2TargetSchema,
@@ -13044,7 +13130,7 @@ var unsignedShareEnvelopeV3BaseSchema = external_exports.object({
13044
13130
  version: external_exports.literal(3),
13045
13131
  shareId: external_exports.string().min(1),
13046
13132
  recipientMatcher: recipientMatcherSchema,
13047
- deliveryEmail: external_exports.string().email().optional(),
13133
+ deliveryEmail: deliveryEmailSchema.optional(),
13048
13134
  actions: external_exports.array(shareActionSchema).min(1).max(3),
13049
13135
  resource: resourceSelectorSchema,
13050
13136
  target: v3TargetSchema,
@@ -14147,6 +14233,7 @@ function prepareAddressedShare(request) {
14147
14233
  }
14148
14234
  if (request.actions.length === 0 || request.policyActions.length === 0) throw new TypeError("addressed share actions are empty");
14149
14235
  if (request.policyActions.some((action) => !OWNER_SHARE_ACTIONS.has(action))) throw new TypeError("addressed share action is not supported");
14236
+ if (request.deliveryEmail !== void 0 && !isEnvelopeDeliveryEmail(request.deliveryEmail)) throw new TypeError("delivery email is not a valid envelope address");
14150
14237
  const target = normalizeShareTarget(request.target);
14151
14238
  if (target.kind === "bearer") throw new TypeError("addressed target is required");
14152
14239
  if (target.kind === "recipientDid") return { target };
@@ -15138,7 +15225,7 @@ function receiveJson(result, path) {
15138
15225
 
15139
15226
  // src/share/io.ts
15140
15227
  import { constants } from "fs";
15141
- 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";
15142
15229
  import { randomBytes as randomBytes2 } from "crypto";
15143
15230
  import { basename as basename2, join as join4, resolve, sep } from "path";
15144
15231
  init_errors();
@@ -15196,7 +15283,7 @@ async function assertDirectory(path) {
15196
15283
  for (const segment of segments) {
15197
15284
  current = current === sep ? join4(current, segment) : join4(current, segment);
15198
15285
  try {
15199
- const info = await lstat(current);
15286
+ const info = await lstat2(current);
15200
15287
  if (info.isSymbolicLink()) {
15201
15288
  const canonical = await realpath(current);
15202
15289
  if (current !== "/tmp" && current !== "/var") throw new Error("OUTPUT_EXISTS");
@@ -15205,7 +15292,7 @@ async function assertDirectory(path) {
15205
15292
  } catch (error) {
15206
15293
  if (error.code !== "ENOENT") throw error;
15207
15294
  await mkdir2(current, { mode: 448 });
15208
- const created = await lstat(current);
15295
+ const created = await lstat2(current);
15209
15296
  if (created.isSymbolicLink() || !created.isDirectory()) throw new Error("OUTPUT_EXISTS");
15210
15297
  }
15211
15298
  }
@@ -15224,7 +15311,7 @@ async function writeShareOutput(directory, filename, bytes, force) {
15224
15311
  };
15225
15312
  await assertStableDirectory();
15226
15313
  const stagingDirectory = await mkdtemp(join4(stableDirectory, ".tinycloud-share-stage-"));
15227
- const stagingInfo = await lstat(stagingDirectory);
15314
+ const stagingInfo = await lstat2(stagingDirectory);
15228
15315
  if (!stagingInfo.isDirectory() || (stagingInfo.mode & 511) !== 448) throw new Error("OUTPUT_EXISTS");
15229
15316
  const stagingPath = join4(stagingDirectory, `.tinycloud-share-${randomBytes2(16).toString("hex")}.tmp`);
15230
15317
  let temporaryPath;
@@ -15232,7 +15319,7 @@ async function writeShareOutput(directory, filename, bytes, force) {
15232
15319
  try {
15233
15320
  await assertStableDirectory();
15234
15321
  try {
15235
- const existing = await lstat(outputPath);
15322
+ const existing = await lstat2(outputPath);
15236
15323
  if (existing.isSymbolicLink()) throw new Error("UNSAFE_FILENAME");
15237
15324
  } catch (error) {
15238
15325
  if (error.code !== "ENOENT") throw error;
@@ -16704,11 +16791,15 @@ var contentMetadataSchema2 = external_exports2.object({
16704
16791
  /** Encrypted presentation discriminator. The fixed entry point is index.html. */
16705
16792
  artifact: external_exports2.literal("html").optional()
16706
16793
  }).strict();
16794
+ var deliveryEmailSchema2 = external_exports2.string().email();
16795
+ function isEnvelopeDeliveryEmail2(value) {
16796
+ return deliveryEmailSchema2.safeParse(value).success;
16797
+ }
16707
16798
  var unsignedShareEnvelopeV2BaseSchema2 = external_exports2.object({
16708
16799
  version: external_exports2.literal(2),
16709
16800
  shareId: external_exports2.string().min(1),
16710
16801
  recipientMatcher: recipientMatcherSchema2,
16711
- deliveryEmail: external_exports2.string().email().optional(),
16802
+ deliveryEmail: deliveryEmailSchema2.optional(),
16712
16803
  actions: external_exports2.array(shareActionSchema2).min(1).max(3),
16713
16804
  resource: resourceSelectorSchema2,
16714
16805
  target: v2TargetSchema2,
@@ -16848,7 +16939,7 @@ var unsignedShareEnvelopeV3BaseSchema2 = external_exports2.object({
16848
16939
  version: external_exports2.literal(3),
16849
16940
  shareId: external_exports2.string().min(1),
16850
16941
  recipientMatcher: recipientMatcherSchema2,
16851
- deliveryEmail: external_exports2.string().email().optional(),
16942
+ deliveryEmail: deliveryEmailSchema2.optional(),
16852
16943
  actions: external_exports2.array(shareActionSchema2).min(1).max(3),
16853
16944
  resource: resourceSelectorSchema2,
16854
16945
  target: v3TargetSchema2,
@@ -17354,6 +17445,12 @@ function createShareAuthorityAdapters(input = {}) {
17354
17445
  policyActions,
17355
17446
  contentSource,
17356
17447
  ...prepared.credentialRequirement === void 0 ? {} : { credentialRequirement: prepared.credentialRequirement },
17448
+ // tinycloud-node 1.17.2 signs a delivery receipt only for the envelope's
17449
+ // own signed delivery address (1.17.3 accepts it and no longer requires
17450
+ // it), so an exact-email share pins its canonical mailbox for `--notify`
17451
+ // and `tc share notify`. A mailbox the envelope's `deliveryEmail` rule
17452
+ // rejects (deployed viewers validate with it) is published unpinned.
17453
+ ...prepared.target.kind === "email" && isEnvelopeDeliveryEmail2(prepared.target.address) ? { deliveryEmail: prepared.target.address } : {},
17357
17454
  filename: targetInput.filename,
17358
17455
  mediaType,
17359
17456
  byteLength,