gigadrive 2.3.0 → 2.4.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.cjs CHANGED
@@ -32,7 +32,7 @@ var import_effect29 = require("effect");
32
32
  // package.json
33
33
  var package_default = {
34
34
  name: "gigadrive",
35
- version: "2.3.0",
35
+ version: "2.4.1",
36
36
  repository: {
37
37
  type: "git",
38
38
  url: "https://github.com/Gigadrive/sdk.git",
@@ -58,7 +58,7 @@ var package_default = {
58
58
  dependencies: {
59
59
  "@effect/cli": "^0.75.0",
60
60
  "@effect/platform": "^0.96.0",
61
- "@effect/platform-node": "^0.106.0",
61
+ "@effect/platform-node": "^0.107.0",
62
62
  "@gigadrive/build-utils": "workspace:*",
63
63
  "@gigadrive/commons": "workspace:*",
64
64
  "@gigadrive/network-config": "workspace:*",
@@ -69,8 +69,8 @@ var package_default = {
69
69
  open: "^10.2.0"
70
70
  },
71
71
  devDependencies: {
72
- "@effect/language-service": "^0.84.0",
73
- "@types/archiver": "^6.0.3",
72
+ "@effect/language-service": "^0.86.0",
73
+ "@types/archiver": "^6.0.4",
74
74
  esbuild: "^0.28.0",
75
75
  typescript: "catalog:"
76
76
  },
@@ -113,6 +113,25 @@ var TokenExchangeError = class extends import_effect.Schema.TaggedError()("Token
113
113
  statusCode: import_effect.Schema.optional(import_effect.Schema.Number)
114
114
  }) {
115
115
  };
116
+ var DeviceAuthorizationError = class extends import_effect.Schema.TaggedError()(
117
+ "DeviceAuthorizationError",
118
+ {
119
+ message: import_effect.Schema.String,
120
+ cause: import_effect.Schema.optional(import_effect.Schema.String)
121
+ }
122
+ ) {
123
+ };
124
+ var AuthorizationDeniedError = class extends import_effect.Schema.TaggedError()(
125
+ "AuthorizationDeniedError",
126
+ {
127
+ message: import_effect.Schema.String
128
+ }
129
+ ) {
130
+ };
131
+ var DeviceCodeExpiredError = class extends import_effect.Schema.TaggedError()("DeviceCodeExpiredError", {
132
+ message: import_effect.Schema.String
133
+ }) {
134
+ };
116
135
  var TokenRefreshError = class extends import_effect.Schema.TaggedError()("TokenRefreshError", {
117
136
  message: import_effect.Schema.String,
118
137
  statusCode: import_effect.Schema.optional(import_effect.Schema.Number)
@@ -310,15 +329,9 @@ var import_effect6 = require("effect");
310
329
 
311
330
  // src/services/auth.ts
312
331
  var import_effect5 = require("effect");
313
- var crypto = __toESM(require("crypto"), 1);
314
- var http = __toESM(require("http"), 1);
332
+ var import_node_process2 = __toESM(require("process"), 1);
315
333
  var import_open = __toESM(require("open"), 1);
316
334
 
317
- // src/services/auth-storage.ts
318
- var import_platform = require("@effect/platform");
319
- var import_effect3 = require("effect");
320
- var os = __toESM(require("os"), 1);
321
-
322
335
  // src/domain.ts
323
336
  var import_effect2 = require("effect");
324
337
  var DeploymentId = import_effect2.Schema.String.pipe(import_effect2.Schema.brand("@Gigadrive/DeploymentId"));
@@ -336,9 +349,18 @@ var OAuthClientConfig = import_effect2.Schema.Struct({
336
349
  issuer: import_effect2.Schema.String,
337
350
  authorizeUrl: import_effect2.Schema.String,
338
351
  tokenUrl: import_effect2.Schema.String,
352
+ deviceAuthorizeUrl: import_effect2.Schema.String,
339
353
  scope: import_effect2.Schema.String,
340
354
  userinfoUrl: import_effect2.Schema.String
341
355
  });
356
+ var DeviceAuthorizationResponse = import_effect2.Schema.Struct({
357
+ device_code: import_effect2.Schema.String,
358
+ user_code: import_effect2.Schema.String,
359
+ verification_uri: import_effect2.Schema.String,
360
+ verification_uri_complete: import_effect2.Schema.optional(import_effect2.Schema.String),
361
+ expires_in: import_effect2.Schema.Number,
362
+ interval: import_effect2.Schema.optional(import_effect2.Schema.Number)
363
+ });
342
364
  var StoredAuthData = import_effect2.Schema.Struct({
343
365
  refreshToken: import_effect2.Schema.String,
344
366
  accessToken: import_effect2.Schema.optional(import_effect2.Schema.String),
@@ -353,7 +375,47 @@ var DevKeyEntry = import_effect2.Schema.Struct({
353
375
  });
354
376
  var DevKeyStore = import_effect2.Schema.Record({ key: import_effect2.Schema.String, value: DevKeyEntry });
355
377
 
378
+ // src/lib/clipboard.ts
379
+ var import_node_child_process = require("child_process");
380
+ var import_node_process = __toESM(require("process"), 1);
381
+ var clipboardCandidates = () => {
382
+ switch (import_node_process.default.platform) {
383
+ case "darwin":
384
+ return [{ cmd: "pbcopy", args: [] }];
385
+ case "win32":
386
+ return [{ cmd: "clip", args: [] }];
387
+ default:
388
+ return [
389
+ { cmd: "wl-copy", args: [] },
390
+ { cmd: "xclip", args: ["-selection", "clipboard"] },
391
+ { cmd: "xsel", args: ["--clipboard", "--input"] }
392
+ ];
393
+ }
394
+ };
395
+ function writeClipboard(text) {
396
+ const candidates = clipboardCandidates();
397
+ const tryNext = (index) => {
398
+ if (index >= candidates.length) return;
399
+ const { cmd, args } = candidates[index];
400
+ let proc;
401
+ try {
402
+ proc = (0, import_node_child_process.spawn)(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
403
+ } catch {
404
+ tryNext(index + 1);
405
+ return;
406
+ }
407
+ proc.on("error", () => tryNext(index + 1));
408
+ proc.stdin?.on("error", () => {
409
+ });
410
+ proc.stdin?.end(text);
411
+ };
412
+ tryNext(0);
413
+ }
414
+
356
415
  // src/services/auth-storage.ts
416
+ var import_platform = require("@effect/platform");
417
+ var import_effect3 = require("effect");
418
+ var os = __toESM(require("os"), 1);
357
419
  var AuthStorageService = class extends import_effect3.Effect.Service()("AuthStorageService", {
358
420
  accessors: true,
359
421
  effect: import_effect3.Effect.gen(function* () {
@@ -442,7 +504,8 @@ var OpenIdDiscoveryDocument = import_effect4.Schema.Struct({
442
504
  issuer: import_effect4.Schema.optional(import_effect4.Schema.String),
443
505
  authorization_endpoint: import_effect4.Schema.optional(import_effect4.Schema.String),
444
506
  token_endpoint: import_effect4.Schema.optional(import_effect4.Schema.String),
445
- userinfo_endpoint: import_effect4.Schema.optional(import_effect4.Schema.String)
507
+ userinfo_endpoint: import_effect4.Schema.optional(import_effect4.Schema.String),
508
+ device_authorization_endpoint: import_effect4.Schema.optional(import_effect4.Schema.String)
446
509
  });
447
510
  var CLI_SCOPES = [
448
511
  // Identity / OIDC
@@ -508,7 +571,7 @@ var OAuthConfigService = class extends import_effect4.Effect.Service()("OAuthCon
508
571
  })
509
572
  )
510
573
  );
511
- if (!decoded.authorization_endpoint || !decoded.token_endpoint || !decoded.userinfo_endpoint) {
574
+ if (!decoded.authorization_endpoint || !decoded.token_endpoint || !decoded.userinfo_endpoint || !decoded.device_authorization_endpoint) {
512
575
  return yield* import_effect4.Effect.fail(
513
576
  new OAuthDiscoveryError({
514
577
  message: "OIDC discovery returned incomplete endpoints"
@@ -520,6 +583,7 @@ var OAuthConfigService = class extends import_effect4.Effect.Service()("OAuthCon
520
583
  issuer: decoded.issuer ?? issuerBase,
521
584
  authorizeUrl: decoded.authorization_endpoint,
522
585
  tokenUrl: decoded.token_endpoint,
586
+ deviceAuthorizeUrl: decoded.device_authorization_endpoint,
523
587
  scope: DEFAULT_SCOPE,
524
588
  userinfoUrl: decoded.userinfo_endpoint
525
589
  };
@@ -533,14 +597,9 @@ var OAuthConfigService = class extends import_effect4.Effect.Service()("OAuthCon
533
597
  };
534
598
 
535
599
  // src/services/auth.ts
536
- var escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/\//g, "&#x2F;");
537
- var base64URLEncode = (buffer) => buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
538
- var generatePKCE = () => {
539
- const codeVerifier = base64URLEncode(crypto.randomBytes(64));
540
- const challengeBytes = crypto.createHash("sha256").update(codeVerifier, "utf8").digest();
541
- const codeChallenge = base64URLEncode(challengeBytes);
542
- return { codeVerifier, codeChallenge };
543
- };
600
+ var DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
601
+ var DEFAULT_POLL_INTERVAL_SECONDS = 5;
602
+ var REQUEST_TIMEOUT_SECONDS = 30;
544
603
  var AuthService = class extends import_effect5.Effect.Service()("AuthService", {
545
604
  accessors: true,
546
605
  dependencies: [OAuthConfigService.Default, AuthStorageService.Default],
@@ -555,115 +614,178 @@ var AuthService = class extends import_effect5.Effect.Service()("AuthService", {
555
614
  yield* storage.remove;
556
615
  yield* import_effect5.Effect.log("Logged out");
557
616
  });
558
- const exchangeCodeForTokens = (config, authCode, redirectUri, codeVerifier) => import_effect5.Effect.gen(function* () {
617
+ const requestDeviceAuthorization = (config) => import_effect5.Effect.gen(function* () {
559
618
  const response = yield* import_effect5.Effect.tryPromise({
560
- try: () => fetch(config.tokenUrl, {
619
+ try: (signal) => fetch(config.deviceAuthorizeUrl, {
561
620
  method: "POST",
562
621
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
563
- body: new URLSearchParams({
564
- grant_type: "authorization_code",
565
- client_id: config.clientId,
566
- code: authCode,
567
- redirect_uri: redirectUri,
568
- code_verifier: codeVerifier
569
- }).toString()
622
+ body: new URLSearchParams({ client_id: config.clientId, scope: config.scope }).toString(),
623
+ signal
570
624
  }),
571
- catch: (error) => new TokenExchangeError({
572
- message: `Token exchange request failed: ${error instanceof Error ? error.message : String(error)}`
625
+ catch: (error) => new DeviceAuthorizationError({
626
+ message: `Device authorization request failed: ${error instanceof Error ? error.message : String(error)}`
573
627
  })
574
628
  });
575
629
  if (!response.ok) {
576
630
  const body = yield* import_effect5.Effect.tryPromise({
577
631
  try: () => response.text(),
578
- catch: () => new TokenExchangeError({ message: "Failed to read token exchange error response" })
632
+ catch: () => new DeviceAuthorizationError({ message: "Failed to read device authorization error response" })
579
633
  });
580
634
  return yield* import_effect5.Effect.fail(
581
- new TokenExchangeError({
582
- message: `Token exchange failed: ${response.status} ${response.statusText} - ${body}`,
583
- statusCode: response.status
635
+ new DeviceAuthorizationError({
636
+ message: `Device authorization failed: ${response.status} ${response.statusText} - ${body}`
584
637
  })
585
638
  );
586
639
  }
587
- const tokens = yield* import_effect5.Effect.tryPromise({
640
+ const json = yield* import_effect5.Effect.tryPromise({
641
+ try: () => response.json(),
642
+ catch: () => new DeviceAuthorizationError({ message: "Failed to parse device authorization response" })
643
+ });
644
+ return yield* import_effect5.Schema.decodeUnknown(DeviceAuthorizationResponse)(json).pipe(
645
+ import_effect5.Effect.mapError(
646
+ () => new DeviceAuthorizationError({ message: "Invalid device authorization response schema" })
647
+ )
648
+ );
649
+ }).pipe(
650
+ import_effect5.Effect.timeoutFail({
651
+ duration: import_effect5.Duration.seconds(REQUEST_TIMEOUT_SECONDS),
652
+ onTimeout: () => new DeviceAuthorizationError({ message: "Device authorization request timed out" })
653
+ })
654
+ );
655
+ const pollTokenOnce = (config, deviceCode) => import_effect5.Effect.gen(function* () {
656
+ const response = yield* import_effect5.Effect.tryPromise({
657
+ try: (signal) => fetch(config.tokenUrl, {
658
+ method: "POST",
659
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
660
+ body: new URLSearchParams({
661
+ grant_type: DEVICE_CODE_GRANT_TYPE,
662
+ client_id: config.clientId,
663
+ device_code: deviceCode
664
+ }).toString(),
665
+ signal
666
+ }),
667
+ catch: (error) => new TokenExchangeError({
668
+ message: `Token poll request failed: ${error instanceof Error ? error.message : String(error)}`
669
+ })
670
+ });
671
+ if (response.ok) {
672
+ const tokens = yield* import_effect5.Effect.tryPromise({
673
+ try: () => response.json(),
674
+ catch: () => new TokenExchangeError({ message: "Failed to parse token response" })
675
+ });
676
+ return { kind: "tokens", tokens };
677
+ }
678
+ const errorBody = yield* import_effect5.Effect.tryPromise({
588
679
  try: () => response.json(),
589
- catch: () => new TokenExchangeError({ message: "Failed to parse token exchange response" })
680
+ catch: () => new TokenExchangeError({ message: "Failed to parse token poll error response" })
590
681
  });
591
- return tokens;
682
+ switch (errorBody.error) {
683
+ case "authorization_pending":
684
+ return { kind: "pending" };
685
+ case "slow_down":
686
+ return { kind: "slow_down" };
687
+ case "access_denied":
688
+ return { kind: "denied" };
689
+ case "expired_token":
690
+ return { kind: "expired" };
691
+ default:
692
+ return yield* import_effect5.Effect.fail(
693
+ new TokenExchangeError({
694
+ message: `Token poll failed: ${response.status} ${response.statusText} - ${errorBody.error ?? "unknown_error"}`,
695
+ statusCode: response.status
696
+ })
697
+ );
698
+ }
592
699
  });
593
- const startCallbackServer = (config, codeChallenge, state) => import_effect5.Effect.async((resume) => {
594
- const server = http.createServer();
595
- const redirectPath = "/callback";
596
- server.listen(0, "127.0.0.1", () => {
597
- const address = server.address();
598
- if (!address || typeof address !== "object") {
599
- server.close(() => {
600
- resume(import_effect5.Effect.fail(new LoginFlowError({ message: "Failed to determine callback server address" })));
601
- });
602
- return;
700
+ const pollForToken = (config, device) => import_effect5.Effect.gen(function* () {
701
+ const deadline = Date.now() + device.expires_in * 1e3;
702
+ let interval = device.interval ?? DEFAULT_POLL_INTERVAL_SECONDS;
703
+ while (true) {
704
+ yield* import_effect5.Effect.sleep(import_effect5.Duration.seconds(interval));
705
+ if (Date.now() >= deadline) {
706
+ return yield* import_effect5.Effect.fail(
707
+ new DeviceCodeExpiredError({
708
+ message: "The login code expired before it was approved. Please run `gigadrive login` again."
709
+ })
710
+ );
603
711
  }
604
- const port = address.port;
605
- const redirectUri = `http://127.0.0.1:${port}${redirectPath}`;
606
- const authUrl = new URL(config.authorizeUrl);
607
- authUrl.searchParams.append("client_id", config.clientId);
608
- authUrl.searchParams.append("redirect_uri", redirectUri);
609
- authUrl.searchParams.append("response_type", "code");
610
- authUrl.searchParams.append("scope", config.scope);
611
- authUrl.searchParams.append("code_challenge", codeChallenge);
612
- authUrl.searchParams.append("code_challenge_method", "S256");
613
- authUrl.searchParams.append("state", state);
614
- import_effect5.Console.log(`Please open this URL in your browser to log in:
615
- ${authUrl.toString()}
616
- `).pipe(import_effect5.Effect.runSync);
617
- import_effect5.Console.log("Waiting for login callback...").pipe(import_effect5.Effect.runSync);
618
- void (0, import_open.default)(authUrl.toString());
619
- server.on("request", (req, res) => {
620
- const reqUrl = new URL(req.url || "", redirectUri);
621
- if (reqUrl.pathname !== "/callback") {
622
- res.writeHead(404).end("Not Found");
623
- return;
624
- }
625
- const authCode = reqUrl.searchParams.get("code");
626
- const receivedState = reqUrl.searchParams.get("state");
627
- const oauthError = reqUrl.searchParams.get("error");
628
- res.writeHead(200, { "Content-Type": "text/html", Connection: "close" });
629
- if (oauthError) {
630
- const safeOauthError = escapeHtml(oauthError);
631
- res.end(`<h1>Login Failed!</h1><p>Error: ${safeOauthError}</p><p>You can close this tab.</p>`);
632
- server.close();
633
- resume(import_effect5.Effect.fail(new LoginFlowError({ message: `OAuth error: ${oauthError}` })));
634
- return;
635
- }
636
- if (receivedState !== state) {
637
- res.end(
638
- "<h1>Login Failed!</h1><p>State mismatch. Possible CSRF attack.</p><p>You can close this tab.</p>"
712
+ const result = yield* pollTokenOnce(config, device.device_code).pipe(
713
+ // A hung poll must not stall the loop: on timeout treat it as pending so
714
+ // the next iteration re-checks the expiry deadline.
715
+ import_effect5.Effect.timeoutTo({
716
+ duration: import_effect5.Duration.seconds(REQUEST_TIMEOUT_SECONDS),
717
+ onSuccess: (value) => value,
718
+ onTimeout: () => ({ kind: "pending" })
719
+ })
720
+ );
721
+ switch (result.kind) {
722
+ case "tokens":
723
+ return result.tokens;
724
+ case "pending":
725
+ continue;
726
+ case "slow_down":
727
+ interval += 5;
728
+ continue;
729
+ case "denied":
730
+ return yield* import_effect5.Effect.fail(new AuthorizationDeniedError({ message: "The login request was denied." }));
731
+ case "expired":
732
+ return yield* import_effect5.Effect.fail(
733
+ new DeviceCodeExpiredError({
734
+ message: "The login code expired before it was approved. Please run `gigadrive login` again."
735
+ })
639
736
  );
640
- server.close();
641
- resume(import_effect5.Effect.fail(new LoginFlowError({ message: "State mismatch" })));
642
- return;
643
- }
644
- if (authCode) {
645
- res.end("<h1>Login Successful!</h1><p>You can close this tab.</p>");
646
- server.close();
647
- resume(import_effect5.Effect.succeed({ authCode, redirectUri }));
648
- } else {
649
- res.end("<h1>Login Failed!</h1><p>No authorization code received.</p><p>You can close this tab.</p>");
650
- server.close();
651
- resume(import_effect5.Effect.fail(new LoginFlowError({ message: "No authorization code received" })));
652
- }
653
- });
654
- server.on("error", (err) => {
655
- server.close();
656
- resume(import_effect5.Effect.fail(new LoginFlowError({ message: `Local server error: ${err.message}` })));
657
- });
737
+ }
738
+ }
739
+ });
740
+ const watchCopyKey = (url) => import_effect5.Effect.async((resume) => {
741
+ const stdin = import_node_process2.default.stdin;
742
+ if (!stdin.isTTY) {
743
+ return;
744
+ }
745
+ stdin.setRawMode(true);
746
+ stdin.resume();
747
+ stdin.setEncoding("utf8");
748
+ const onData = (key) => {
749
+ if (key === "c" || key === "C") {
750
+ writeClipboard(url);
751
+ import_effect5.Console.log("Copied the login URL to your clipboard.").pipe(import_effect5.Effect.runSync);
752
+ } else if (key === "") {
753
+ resume(import_effect5.Effect.fail(new LoginFlowError({ message: "Login canceled." })));
754
+ }
755
+ };
756
+ stdin.on("data", onData);
757
+ return import_effect5.Effect.sync(() => {
758
+ stdin.off("data", onData);
759
+ if (stdin.isTTY) stdin.setRawMode(false);
760
+ stdin.pause();
658
761
  });
659
762
  });
660
763
  const login = import_effect5.Effect.gen(function* () {
661
764
  yield* logout;
662
- const { codeVerifier, codeChallenge } = generatePKCE();
663
- const state = crypto.randomBytes(16).toString("hex");
664
765
  const config = yield* oauthConfig.getConfig;
665
- const { authCode, redirectUri } = yield* startCallbackServer(config, codeChallenge, state);
666
- const tokens = yield* exchangeCodeForTokens(config, authCode, redirectUri, codeVerifier);
766
+ const device = yield* requestDeviceAuthorization(config);
767
+ const verificationUri = device.verification_uri;
768
+ const verificationUriComplete = device.verification_uri_complete ?? verificationUri;
769
+ const interactive = import_node_process2.default.stdin.isTTY === true;
770
+ yield* import_effect5.Console.log(
771
+ `
772
+ To sign in, visit:
773
+
774
+ ${verificationUri}
775
+
776
+ and enter the code:
777
+
778
+ ${device.user_code}
779
+ `
780
+ );
781
+ if (interactive) {
782
+ yield* import_effect5.Effect.tryPromise(() => (0, import_open.default)(verificationUriComplete)).pipe(import_effect5.Effect.catchAll(() => import_effect5.Effect.void));
783
+ yield* import_effect5.Console.log("We tried to open your browser automatically. If it did not open, use the URL above.");
784
+ yield* import_effect5.Console.log('Press "c" to copy the URL to your clipboard, or Ctrl+C to cancel.');
785
+ }
786
+ yield* import_effect5.Console.log("\nWaiting for you to approve the login\u2026");
787
+ const poll = pollForToken(config, device);
788
+ const tokens = yield* interactive ? poll.pipe(import_effect5.Effect.raceFirst(watchCopyKey(verificationUriComplete))) : poll;
667
789
  if (!tokens.refresh_token) {
668
790
  return yield* import_effect5.Effect.fail(
669
791
  new TokenExchangeError({ message: "No refresh token received from identity provider" })
@@ -2160,7 +2282,10 @@ var loginCommand = import_cli9.Command.make(
2160
2282
  yield* import_effect21.Console.log(`You are now logged in as ${name}.`);
2161
2283
  }).pipe(
2162
2284
  import_effect21.Effect.catchTags({
2163
- LoginFlowError: (err) => import_effect21.Console.error(`Login failed: ${err.message}`),
2285
+ LoginFlowError: (err) => import_effect21.Console.error(err.message),
2286
+ DeviceAuthorizationError: (err) => import_effect21.Console.error(`Login failed: ${err.message}`),
2287
+ AuthorizationDeniedError: (err) => import_effect21.Console.error(err.message),
2288
+ DeviceCodeExpiredError: (err) => import_effect21.Console.error(err.message),
2164
2289
  TokenExchangeError: (err) => import_effect21.Console.error(`Login failed: ${err.message}`),
2165
2290
  OAuthDiscoveryError: (err) => import_effect21.Console.error(`Login failed: ${err.message}`),
2166
2291
  AuthStorageWriteError: (err) => import_effect21.Console.error(`Failed to save credentials: ${err.message}`)