@tinycloud/cli 1.1.0-beta.7 → 1.1.0-beta.9

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
@@ -72,7 +72,7 @@ var init_theme = __esm({
72
72
 
73
73
  // src/output/formatter.ts
74
74
  import ora from "ora";
75
- function outputError(code3, message, hint) {
75
+ function outputError(code3, message, hint, meta) {
76
76
  if (isInteractive()) {
77
77
  process.stderr.write(
78
78
  `${theme.error("\u2717")} ${theme.label(code3)}: ${message}
@@ -89,6 +89,7 @@ function outputError(code3, message, hint) {
89
89
  error: { code: code3, message }
90
90
  };
91
91
  if (hint) payload.error.hint = hint;
92
+ if (meta) payload.error.meta = meta;
92
93
  process.stderr.write(JSON.stringify(payload, null, 2) + "\n");
93
94
  }
94
95
  }
@@ -110,24 +111,24 @@ var init_formatter = __esm({
110
111
  // src/output/errors.ts
111
112
  import { readFileSync, readdirSync } from "fs";
112
113
  import { join } from "path";
113
- import { ProfileLockTimeoutError } from "@tinycloud/operations/state";
114
+ import { ProfileDeletedError, ProfileLockTimeoutError } from "@tinycloud/operations/state";
115
+ import { authorizationVerdictOf, parseCapabilityResource, SERVICE_LONG_TO_SHORT, validatedCapabilityOf } from "@tinycloud/sdk-core";
114
116
  function setActiveProfileName(name) {
115
117
  activeProfileName = name;
116
118
  }
117
119
  function wrapError(error) {
120
+ if (error instanceof CLIError) return error;
118
121
  const message = error instanceof Error ? error.message : String(error);
119
- if (message.includes("Missing private key parameter in JWK")) {
120
- const profileName = activeProfileName ?? process.env.TC_PROFILE ?? DEFAULT_PROFILE;
121
- return new CLIError(
122
- "AUTH_REQUIRED",
123
- `Profile "${profileName}" cannot restore its session because its private key material is missing.`,
124
- ExitCode.AUTH_REQUIRED,
125
- {
126
- hint: `Sign in again with: tc --profile ${profileName} auth login --method openkey`
127
- }
128
- );
122
+ const verdict = authorizationVerdictOf(error);
123
+ if (verdict === "unauthenticated") {
124
+ return new CLIError("AUTH_REQUIRED", message, ExitCode.AUTH_REQUIRED);
125
+ }
126
+ if (verdict === "forbidden") {
127
+ return new CLIError("PERMISSION_DENIED", message, ExitCode.PERMISSION_DENIED);
128
+ }
129
+ if (verdict === void 0 && message.includes("Missing private key parameter in JWK")) {
130
+ return missingPrivateKeyError();
129
131
  }
130
- if (error instanceof CLIError) return error;
131
132
  if (error instanceof ProfileLockTimeoutError) {
132
133
  return new CLIError(
133
134
  "PROFILE_LOCK_TIMEOUT",
@@ -136,13 +137,16 @@ function wrapError(error) {
136
137
  { hint: "Wait for the other command to finish and retry. A crashed process's lock is reclaimed automatically after 30 s." }
137
138
  );
138
139
  }
139
- if (message.includes("Not signed in") || message.includes("AUTH_EXPIRED") || message.includes("Session expired")) {
140
+ if (error instanceof ProfileDeletedError) {
141
+ return new CLIError("PROFILE_NOT_FOUND", message);
142
+ }
143
+ if (verdict === void 0 && (message.includes("Not signed in") || message.includes("AUTH_EXPIRED") || message.includes("Session expired"))) {
140
144
  return new CLIError("AUTH_REQUIRED", message, ExitCode.AUTH_REQUIRED);
141
145
  }
142
146
  if (message.includes("NOT_FOUND") || message.includes("KV_NOT_FOUND")) {
143
147
  return new CLIError("NOT_FOUND", message, ExitCode.NOT_FOUND);
144
148
  }
145
- if (message.includes("PERMISSION_DENIED")) {
149
+ if (verdict === void 0 && message.includes("PERMISSION_DENIED")) {
146
150
  return new CLIError("PERMISSION_DENIED", message, ExitCode.PERMISSION_DENIED);
147
151
  }
148
152
  if (message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT") || message.includes("fetch failed")) {
@@ -150,39 +154,51 @@ function wrapError(error) {
150
154
  }
151
155
  return new CLIError("ERROR", message, ExitCode.ERROR);
152
156
  }
157
+ function missingPrivateKeyError() {
158
+ const profileName = activeProfileName ?? process.env.TC_PROFILE ?? DEFAULT_PROFILE;
159
+ return new CLIError(
160
+ "AUTH_REQUIRED",
161
+ `Profile "${profileName}" cannot restore its session because its private key material is missing.`,
162
+ ExitCode.AUTH_REQUIRED,
163
+ { hint: `Sign in again with: tc --profile ${profileName} auth login --method openkey` }
164
+ );
165
+ }
153
166
  function handleError(error) {
154
167
  const cliError = wrapError(error);
155
168
  const prebuilt = typeof cliError.metadata?.hint === "string" ? cliError.metadata.hint : void 0;
156
169
  const hint = prebuilt ?? buildAuthHint(cliError) ?? (cliError.code === "NETWORK_ERROR" ? buildNetworkHint() : void 0);
157
- outputError(cliError.code, cliError.message, hint);
170
+ const authMeta = cliError.code === "AUTH_REQUIRED" || cliError.code === "PERMISSION_DENIED" ? cliError.metadata : void 0;
171
+ const meta = {};
172
+ if (cliError.status !== void 0 && authMeta !== void 0) meta.status = cliError.status;
173
+ const capability = validatedCapabilityOf({ meta: authMeta });
174
+ if (capability) {
175
+ meta.resource = capability.resource;
176
+ meta.requiredAction = capability.requiredAction;
177
+ }
178
+ outputError(cliError.code, cliError.message, hint, Object.keys(meta).length ? meta : void 0);
158
179
  process.exit(cliError.exitCode);
159
180
  }
160
181
  function buildAuthHint(error) {
161
- const resource = error.metadata?.resource;
162
- const requiredAction = error.metadata?.requiredAction;
163
- if (typeof resource !== "string" || typeof requiredAction !== "string") {
164
- return void 0;
165
- }
166
- const spec = capSpecFromAuthMeta(resource, requiredAction);
182
+ const capability = validatedCapabilityOf({ meta: error.metadata });
183
+ if (!capability) return void 0;
184
+ const spec = capSpecFromAuthMeta(capability.resource, capability.requiredAction);
167
185
  if (!spec) return void 0;
168
186
  return [
169
187
  "The active session is missing a TinyCloud capability.",
170
- `Request it with: tc auth request --cap "${spec}"`,
188
+ `Request it with: tc auth request --cap '${spec.replaceAll("'", "'\\''")}'`,
171
189
  "Then retry the original command."
172
190
  ].join("\n");
173
191
  }
174
192
  function capSpecFromAuthMeta(resource, action) {
175
- const slash = resource.indexOf("/");
176
- if (slash <= 0 || slash === resource.length - 1) return void 0;
177
- const spaceUri = resource.slice(0, slash);
178
- const rest = resource.slice(slash + 1);
179
- const nextSlash = rest.indexOf("/");
180
- if (nextSlash <= 0) return void 0;
181
- const serviceShort = rest.slice(0, nextSlash);
182
- const path = rest.slice(nextSlash + 1);
183
- const actionName = action.includes("/") ? action.slice(action.indexOf("/") + 1) : action;
184
- const spaceName = spaceUri.startsWith("tinycloud:") ? spaceUri.slice(spaceUri.lastIndexOf(":") + 1) : spaceUri;
185
- return `tinycloud.${serviceShort}:${spaceName}:${path}:${actionName}`;
193
+ const slash = action.indexOf("/");
194
+ if (slash < 0) return void 0;
195
+ const longService = action.slice(0, slash);
196
+ const serviceShort = SERVICE_LONG_TO_SHORT[longService];
197
+ if (!serviceShort) return void 0;
198
+ const parsed = parseCapabilityResource(resource, serviceShort);
199
+ if (!parsed) return void 0;
200
+ const spaceName = parsed.space.slice(parsed.space.lastIndexOf(":") + 1);
201
+ return `${longService}:${spaceName}:${parsed.path}:${action.slice(slash + 1)}`;
186
202
  }
187
203
  function buildNetworkHint() {
188
204
  const readHost = (name) => {
@@ -236,7 +252,9 @@ var init_errors = __esm({
236
252
  this.exitCode = exitCode;
237
253
  this.metadata = metadata;
238
254
  this.name = "CLIError";
255
+ if (typeof metadata?.status === "number" && Number.isInteger(metadata.status) && metadata.status >= 400 && metadata.status <= 599) this.status = metadata.status;
239
256
  }
257
+ status;
240
258
  };
241
259
  }
242
260
  });
@@ -541,6 +559,8 @@ import { join as join3 } from "path";
541
559
  import {
542
560
  profilePath,
543
561
  readSession,
562
+ recordProfileDeletion,
563
+ refuseWriteToDeletedProfile,
544
564
  removeSession,
545
565
  withProfileLock,
546
566
  writeSession
@@ -655,10 +675,12 @@ var init_profiles = __esm({
655
675
  * removed while holding the profile lock, so another writer's critical
656
676
  * section never sees them vanish midway: session and key first, settings
657
677
  * 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.
678
+ * profile. The deletion is then recorded, so a store write that waited for
679
+ * the lock meanwhile refuses rather than recreating a profile with only a
680
+ * session in it. `.lock` itself is left to the lock's release; the
681
+ * then-empty directory is removed afterwards unless another writer took
682
+ * the lock (or wrote) meanwhile. A profile directory that is a symlink is
683
+ * unlinked, its target left alone.
662
684
  * Throws if the name is not one path segment or names the default profile.
663
685
  */
664
686
  static async deleteProfile(name) {
@@ -679,11 +701,12 @@ var init_profiles = __esm({
679
701
  `Cannot delete the default profile "${name}". Change the default first with \`tc profile default <other>\`.`
680
702
  );
681
703
  }
682
- const isLink = await lstat(profileDir).then((stats) => stats.isSymbolicLink(), (error) => {
683
- if (error.code === "ENOENT") return false;
704
+ const kind = await lstat(profileDir).then((stats) => stats.isSymbolicLink() ? "link" : "present", (error) => {
705
+ if (error.code === "ENOENT") return "missing";
684
706
  throw error;
685
707
  });
686
- if (isLink) {
708
+ if (kind === "missing") return;
709
+ if (kind === "link") {
687
710
  await rm2(profileDir, { force: true });
688
711
  return;
689
712
  }
@@ -693,6 +716,7 @@ var init_profiles = __esm({
693
716
  if (entry !== ".lock" && entry !== "profile.json") await rm2(join3(profileDir, entry), { recursive: true, force: true });
694
717
  }
695
718
  await rm2(join3(profileDir, "profile.json"), { force: true });
719
+ await recordProfileDeletion(name);
696
720
  });
697
721
  await rmdir(profileDir).catch((error) => {
698
722
  if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY" && error.code !== "EEXIST") throw error;
@@ -705,9 +729,14 @@ var init_profiles = __esm({
705
729
  static async getKey(name) {
706
730
  return readJson(join3(PROFILES_DIR, name, "key.json"));
707
731
  }
708
- /** Saves a JWK key under the profile lock (0600, in an owner-only profile directory). */
732
+ /**
733
+ * Saves a JWK key under the profile lock (0600, in an owner-only profile
734
+ * directory). Refused (PROFILE_NOT_FOUND) if the profile was deleted while
735
+ * this waited for the lock, rather than leaving a key-only profile.
736
+ */
709
737
  static async setKey(name, jwk) {
710
738
  await _ProfileManager.withLock(name, async () => {
739
+ await refuseWriteToDeletedProfile(name);
711
740
  await writeJson(join3(await _ProfileManager.ensureProfileDir(name), "key.json"), jwk);
712
741
  });
713
742
  }
@@ -5659,6 +5688,7 @@ import {
5659
5688
  profileStoreMetadataPath,
5660
5689
  readAdditionalDelegations,
5661
5690
  readAuthRequests,
5691
+ refuseWriteToDeletedProfile as refuseWriteToDeletedProfile2,
5662
5692
  updateProfileStore,
5663
5693
  withProfileLock as withProfileLock2,
5664
5694
  writeJsonAtomic
@@ -5885,13 +5915,13 @@ async function bootstrapDelegatedSession(ctx, delegation) {
5885
5915
  throw error;
5886
5916
  });
5887
5917
  const session = await ProfileManager.getSession(ctx.profile);
5888
- if (JSON.stringify(profile) !== JSON.stringify(written.profile) || JSON.stringify(session) !== JSON.stringify(written.session)) {
5918
+ if (profile === null || BOOTSTRAP_PROFILE_FIELDS.some((field) => profile[field] !== written.profile[field]) || JSON.stringify(session) !== JSON.stringify(written.session)) {
5889
5919
  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.`;
5890
5920
  }
5891
5921
  return restoreBeforeBootstrap(ctx.profile, written.previousProfile);
5892
5922
  }, { timeoutMs: PROFILE_COMMIT_LOCK_TIMEOUT_MS }).catch((error) => (
5893
5923
  // The lock or a read failed: the import's error stays the one reported.
5894
- `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\`.`
5924
+ `Rolling back the provisional session of profile "${ctx.profile}" could not run (${failureName(error)}); check \`tc --profile ${ctx.profile} context\`.`
5895
5925
  ));
5896
5926
  throw annotate(cause, note);
5897
5927
  };
@@ -5907,15 +5937,27 @@ async function restoreBeforeBootstrap(profileName, previousProfile) {
5907
5937
  const failures = [];
5908
5938
  for (const write of [
5909
5939
  () => ProfileManager.clearSession(profileName),
5910
- () => ProfileManager.setProfile(profileName, previousProfile)
5940
+ () => ProfileManager.updateProfile(profileName, (current) => {
5941
+ const restored = { ...current };
5942
+ for (const field of BOOTSTRAP_PROFILE_FIELDS) {
5943
+ if (previousProfile[field] === void 0) delete restored[field];
5944
+ else restored[field] = previousProfile[field];
5945
+ }
5946
+ return restored;
5947
+ })
5911
5948
  ]) {
5912
5949
  await write().catch((error) => {
5913
- failures.push(error instanceof Error ? error.message : String(error));
5950
+ failures.push(failureName(error));
5914
5951
  });
5915
5952
  }
5916
5953
  if (failures.length === 0) return void 0;
5917
5954
  return `Rolling back the provisional session of profile "${profileName}" failed too (${failures.join("; ")}); check \`tc --profile ${profileName} context\`.`;
5918
5955
  }
5956
+ function failureName(error) {
5957
+ if (error instanceof SyntaxError) return "a profile file is not valid JSON";
5958
+ if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") return error.code;
5959
+ return error instanceof Error ? error.name : "unknown error";
5960
+ }
5919
5961
  function annotate(error, note) {
5920
5962
  if (note === void 0) return error;
5921
5963
  const cause = wrapError(error);
@@ -5939,6 +5981,7 @@ async function ensureAuthenticated(ctx, options) {
5939
5981
  }
5940
5982
  return createSDKInstance(ctx, options);
5941
5983
  }
5984
+ var BOOTSTRAP_PROFILE_FIELDS;
5942
5985
  var init_sdk = __esm({
5943
5986
  "src/lib/sdk.ts"() {
5944
5987
  "use strict";
@@ -5947,6 +5990,7 @@ var init_sdk = __esm({
5947
5990
  init_errors();
5948
5991
  init_constants();
5949
5992
  init_permissions();
5993
+ BOOTSTRAP_PROFILE_FIELDS = ["sessionDid", "spaceId"];
5950
5994
  }
5951
5995
  });
5952
5996
 
@@ -14990,7 +15034,6 @@ async function notifyShare(input) {
14990
15034
  };
14991
15035
  }
14992
15036
  let attempts = 0;
14993
- let lastError;
14994
15037
  while (attempts < attemptsLimit) {
14995
15038
  if (input.signal?.aborted) throw new ShareNotifyError("share delivery was cancelled");
14996
15039
  attempts += 1;
@@ -15017,10 +15060,19 @@ async function notifyShare(input) {
15017
15060
  reason: "delivery-window-expired"
15018
15061
  };
15019
15062
  }
15020
- lastError = error;
15063
+ if (error !== null && typeof error === "object" && "status" in error && (error.status === 401 || error.status === 403)) {
15064
+ return {
15065
+ protocol: "tinycloud-share",
15066
+ version: 1,
15067
+ shareId: input.shareId,
15068
+ state: "partial-failure",
15069
+ idempotencyKey,
15070
+ attempts,
15071
+ retryable: false
15072
+ };
15073
+ }
15021
15074
  }
15022
15075
  }
15023
- void lastError;
15024
15076
  return { protocol: "tinycloud-share", version: 1, shareId: input.shareId, state: "partial-failure", idempotencyKey, attempts, retryable: true };
15025
15077
  }
15026
15078
  async function defaultIdempotencyKey(shareId, recipient) {