alchemy 2.0.0-beta.15 → 2.0.0-beta.16

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.
Files changed (85) hide show
  1. package/bin/alchemy.js +1585 -707
  2. package/bin/alchemy.js.map +1 -1
  3. package/bin/alchemy.ts +2 -0
  4. package/bin/commands/login.ts +6 -3
  5. package/bin/commands/profile.ts +86 -0
  6. package/lib/Cloudflare/ApiToken/AccountApiToken.d.ts +2 -3
  7. package/lib/Cloudflare/ApiToken/AccountApiToken.d.ts.map +1 -1
  8. package/lib/Cloudflare/ApiToken/AccountApiToken.js +12 -8
  9. package/lib/Cloudflare/ApiToken/AccountApiToken.js.map +1 -1
  10. package/lib/Cloudflare/ApiToken/Common.d.ts +6 -1
  11. package/lib/Cloudflare/ApiToken/Common.d.ts.map +1 -1
  12. package/lib/Cloudflare/ApiToken/Common.js.map +1 -1
  13. package/lib/Cloudflare/ApiToken/UserApiToken.d.ts +2 -3
  14. package/lib/Cloudflare/ApiToken/UserApiToken.d.ts.map +1 -1
  15. package/lib/Cloudflare/ApiToken/UserApiToken.js +4 -4
  16. package/lib/Cloudflare/ApiToken/UserApiToken.js.map +1 -1
  17. package/lib/Cloudflare/Artifacts/Artifacts.d.ts +101 -0
  18. package/lib/Cloudflare/Artifacts/Artifacts.d.ts.map +1 -0
  19. package/lib/Cloudflare/Artifacts/Artifacts.js +92 -0
  20. package/lib/Cloudflare/Artifacts/Artifacts.js.map +1 -0
  21. package/lib/Cloudflare/Artifacts/ArtifactsBinding.d.ts +83 -0
  22. package/lib/Cloudflare/Artifacts/ArtifactsBinding.d.ts.map +1 -0
  23. package/lib/Cloudflare/Artifacts/ArtifactsBinding.js +65 -0
  24. package/lib/Cloudflare/Artifacts/ArtifactsBinding.js.map +1 -0
  25. package/lib/Cloudflare/Artifacts/index.d.ts +3 -0
  26. package/lib/Cloudflare/Artifacts/index.d.ts.map +1 -0
  27. package/lib/Cloudflare/Artifacts/index.js +3 -0
  28. package/lib/Cloudflare/Artifacts/index.js.map +1 -0
  29. package/lib/Cloudflare/Container/ContainerBinding.d.ts +1 -1
  30. package/lib/Cloudflare/Providers.d.ts.map +1 -1
  31. package/lib/Cloudflare/Providers.js +3 -1
  32. package/lib/Cloudflare/Providers.js.map +1 -1
  33. package/lib/Cloudflare/Workers/InferEnv.d.ts +1 -1
  34. package/lib/Cloudflare/Workers/InferEnv.d.ts.map +1 -1
  35. package/lib/Cloudflare/Workers/InferEnv.js +0 -1
  36. package/lib/Cloudflare/Workers/InferEnv.js.map +1 -1
  37. package/lib/Cloudflare/Workers/Worker.d.ts +2 -1
  38. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  39. package/lib/Cloudflare/Workers/Worker.js +25 -18
  40. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  41. package/lib/Cloudflare/index.d.ts +1 -0
  42. package/lib/Cloudflare/index.d.ts.map +1 -1
  43. package/lib/Cloudflare/index.js +1 -0
  44. package/lib/Cloudflare/index.js.map +1 -1
  45. package/lib/GitHub/AuthProvider.d.ts +37 -0
  46. package/lib/GitHub/AuthProvider.d.ts.map +1 -0
  47. package/lib/GitHub/AuthProvider.js +154 -0
  48. package/lib/GitHub/AuthProvider.js.map +1 -0
  49. package/lib/GitHub/Credentials.d.ts +29 -0
  50. package/lib/GitHub/Credentials.d.ts.map +1 -0
  51. package/lib/GitHub/Credentials.js +54 -0
  52. package/lib/GitHub/Credentials.js.map +1 -0
  53. package/lib/GitHub/Providers.d.ts +12 -1
  54. package/lib/GitHub/Providers.d.ts.map +1 -1
  55. package/lib/GitHub/Providers.js +13 -3
  56. package/lib/GitHub/Providers.js.map +1 -1
  57. package/lib/GitHub/Secret.d.ts +8 -10
  58. package/lib/GitHub/Secret.d.ts.map +1 -1
  59. package/lib/GitHub/Secret.js +16 -14
  60. package/lib/GitHub/Secret.js.map +1 -1
  61. package/lib/GitHub/Variable.d.ts +8 -10
  62. package/lib/GitHub/Variable.d.ts.map +1 -1
  63. package/lib/GitHub/Variable.js +12 -13
  64. package/lib/GitHub/Variable.js.map +1 -1
  65. package/lib/GitHub/index.d.ts +2 -0
  66. package/lib/GitHub/index.d.ts.map +1 -1
  67. package/lib/GitHub/index.js +2 -0
  68. package/lib/GitHub/index.js.map +1 -1
  69. package/package.json +4 -4
  70. package/src/Cloudflare/ApiToken/AccountApiToken.ts +21 -11
  71. package/src/Cloudflare/ApiToken/Common.ts +6 -1
  72. package/src/Cloudflare/ApiToken/UserApiToken.ts +6 -8
  73. package/src/Cloudflare/Artifacts/Artifacts.ts +139 -0
  74. package/src/Cloudflare/Artifacts/ArtifactsBinding.ts +188 -0
  75. package/src/Cloudflare/Artifacts/index.ts +2 -0
  76. package/src/Cloudflare/Providers.ts +3 -0
  77. package/src/Cloudflare/Workers/InferEnv.ts +5 -4
  78. package/src/Cloudflare/Workers/Worker.ts +12 -1
  79. package/src/Cloudflare/index.ts +1 -0
  80. package/src/GitHub/AuthProvider.ts +309 -0
  81. package/src/GitHub/Credentials.ts +99 -0
  82. package/src/GitHub/Providers.ts +23 -3
  83. package/src/GitHub/Secret.ts +22 -24
  84. package/src/GitHub/Variable.ts +16 -23
  85. package/src/GitHub/index.ts +2 -0
@@ -0,0 +1,188 @@
1
+ import * as Data from "effect/Data";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as Binding from "../../Binding.ts";
5
+ import type { ResourceLike } from "../../Resource.ts";
6
+ import { isWorker, WorkerEnvironment } from "../Workers/Worker.ts";
7
+ import { type Artifacts as ArtifactsLike } from "./Artifacts.ts";
8
+
9
+ export class ArtifactsError extends Data.TaggedError("ArtifactsError")<{
10
+ message: string;
11
+ cause: Error;
12
+ }> {}
13
+
14
+ export type Scope = "read" | "write";
15
+
16
+ export type ArtifactsCreateOptions = {
17
+ readOnly?: boolean;
18
+ description?: string;
19
+ setDefaultBranch?: string;
20
+ };
21
+
22
+ export type ArtifactsImportOptions = {
23
+ source: { url: string; branch?: string; depth?: number };
24
+ target: {
25
+ name: string;
26
+ opts?: { description?: string; readOnly?: boolean };
27
+ };
28
+ };
29
+
30
+ export type ArtifactsListOptions = {
31
+ limit?: number;
32
+ cursor?: string;
33
+ };
34
+
35
+ export type ArtifactsForkOptions = {
36
+ description?: string;
37
+ readOnly?: boolean;
38
+ defaultBranchOnly?: boolean;
39
+ };
40
+
41
+ /**
42
+ * Effect-native handle to a single Artifacts repo. Wraps the runtime
43
+ * {@link ArtifactsRepo} so each method returns an Effect.
44
+ */
45
+ export interface ArtifactsRepoClient {
46
+ /** Underlying Cloudflare runtime handle. */
47
+ raw: ArtifactsRepo;
48
+ createToken(
49
+ scope?: Scope,
50
+ ttl?: number,
51
+ ): Effect.Effect<ArtifactsCreateTokenResult, ArtifactsError>;
52
+ listTokens(): Effect.Effect<ArtifactsTokenListResult, ArtifactsError>;
53
+ revokeToken(tokenOrId: string): Effect.Effect<boolean, ArtifactsError>;
54
+ fork(
55
+ name: string,
56
+ opts?: ArtifactsForkOptions,
57
+ ): Effect.Effect<ArtifactsCreateRepoResult, ArtifactsError>;
58
+ }
59
+
60
+ /**
61
+ * Effect-native client for a Cloudflare Artifacts namespace binding.
62
+ *
63
+ * Wraps the runtime {@link Artifacts} binding so each method returns an
64
+ * Effect tagged with {@link ArtifactsError}. Use
65
+ * `Cloudflare.ArtifactsBinding.bind(Repos)` inside a Worker's init phase.
66
+ */
67
+ export interface ArtifactsClient {
68
+ /** Effect resolving to the raw Cloudflare runtime binding. */
69
+ raw: Effect.Effect<Artifacts, never, WorkerEnvironment>;
70
+ create(
71
+ name: string,
72
+ opts?: ArtifactsCreateOptions,
73
+ ): Effect.Effect<
74
+ ArtifactsCreateRepoResult,
75
+ ArtifactsError,
76
+ WorkerEnvironment
77
+ >;
78
+ /** Look up an existing repo by name. Fails with `ArtifactsError` if missing. */
79
+ get(
80
+ name: string,
81
+ ): Effect.Effect<ArtifactsRepoClient, ArtifactsError, WorkerEnvironment>;
82
+ list(
83
+ opts?: ArtifactsListOptions,
84
+ ): Effect.Effect<ArtifactsRepoListResult, ArtifactsError, WorkerEnvironment>;
85
+ delete(
86
+ name: string,
87
+ ): Effect.Effect<boolean, ArtifactsError, WorkerEnvironment>;
88
+ import(
89
+ opts: ArtifactsImportOptions,
90
+ ): Effect.Effect<
91
+ ArtifactsCreateRepoResult,
92
+ ArtifactsError,
93
+ WorkerEnvironment
94
+ >;
95
+ }
96
+
97
+ export class ArtifactsBinding extends Binding.Service<
98
+ ArtifactsBinding,
99
+ (artifacts: ArtifactsLike) => Effect.Effect<ArtifactsClient>
100
+ >()("Cloudflare.Artifacts.Binding") {}
101
+
102
+ export const ArtifactsBindingLive = Layer.effect(
103
+ ArtifactsBinding,
104
+ Effect.gen(function* () {
105
+ const Policy = yield* ArtifactsBindingPolicy;
106
+
107
+ return Effect.fn(function* (artifacts: ArtifactsLike) {
108
+ yield* Policy(artifacts);
109
+ const env = WorkerEnvironment.asEffect();
110
+ const raw = env.pipe(
111
+ Effect.map(
112
+ (env) => (env as Record<string, Artifacts>)[artifacts.name]!,
113
+ ),
114
+ );
115
+
116
+ const use = <T>(
117
+ fn: (raw: Artifacts) => Promise<T>,
118
+ ): Effect.Effect<T, ArtifactsError, WorkerEnvironment> =>
119
+ raw.pipe(Effect.flatMap((raw) => tryPromise(() => fn(raw))));
120
+
121
+ return {
122
+ raw,
123
+ create: (name, opts) => use((raw) => raw.create(name, opts)),
124
+ get: (name) =>
125
+ use((raw) => raw.get(name)).pipe(
126
+ Effect.flatMap((repo) =>
127
+ repo == null
128
+ ? Effect.fail(
129
+ new ArtifactsError({
130
+ message: `Artifacts repo '${name}' not found`,
131
+ cause: new Error("not_found"),
132
+ }),
133
+ )
134
+ : Effect.succeed(wrapRepo(repo as ArtifactsRepo)),
135
+ ),
136
+ ),
137
+ list: (opts) => use((raw) => raw.list(opts)),
138
+ delete: (name) => use((raw) => raw.delete(name)),
139
+ import: (opts) => use((raw) => raw.import(opts)),
140
+ } satisfies ArtifactsClient;
141
+ });
142
+ }),
143
+ );
144
+
145
+ export class ArtifactsBindingPolicy extends Binding.Policy<
146
+ ArtifactsBindingPolicy,
147
+ (artifacts: ArtifactsLike) => Effect.Effect<void>
148
+ >()("Cloudflare.Artifacts.Binding") {}
149
+
150
+ export const ArtifactsBindingPolicyLive = ArtifactsBindingPolicy.layer.succeed(
151
+ Effect.fn(function* (host: ResourceLike, artifacts: ArtifactsLike) {
152
+ if (isWorker(host)) {
153
+ yield* host.bind(artifacts.name, {
154
+ bindings: [
155
+ {
156
+ type: "artifacts",
157
+ name: artifacts.name,
158
+ namespace: artifacts.namespace,
159
+ } as any,
160
+ ],
161
+ });
162
+ } else {
163
+ return yield* Effect.die(
164
+ new Error(`ArtifactsBinding does not support runtime '${host.Type}'`),
165
+ );
166
+ }
167
+ }),
168
+ );
169
+
170
+ const tryPromise = <T>(
171
+ fn: () => Promise<T>,
172
+ ): Effect.Effect<T, ArtifactsError> =>
173
+ Effect.tryPromise({
174
+ try: fn,
175
+ catch: (error: any) =>
176
+ new ArtifactsError({
177
+ message: error?.message ?? "Unknown error",
178
+ cause: error,
179
+ }),
180
+ });
181
+
182
+ const wrapRepo = (raw: ArtifactsRepo): ArtifactsRepoClient => ({
183
+ raw,
184
+ createToken: (scope, ttl) => tryPromise(() => raw.createToken(scope, ttl)),
185
+ listTokens: () => tryPromise(() => raw.listTokens()),
186
+ revokeToken: (tokenOrId) => tryPromise(() => raw.revokeToken(tokenOrId)),
187
+ fork: (name, opts) => tryPromise(() => raw.fork(name, opts)),
188
+ });
@@ -0,0 +1,2 @@
1
+ export * from "./Artifacts.ts";
2
+ export * from "./ArtifactsBinding.ts";
@@ -4,6 +4,7 @@ import * as Build from "../Build/index.ts";
4
4
  import * as Provider from "../Provider.ts";
5
5
  import { Random, RandomProvider } from "../Random.ts";
6
6
  import * as ApiToken from "./ApiToken/index.ts";
7
+ import * as Artifacts from "./Artifacts/index.ts";
7
8
  import { CloudflareAuth } from "./Auth/AuthProvider.ts";
8
9
  import * as CloudflareEnvironment from "./CloudflareEnvironment.ts";
9
10
  import * as Containers from "./Container/index.ts";
@@ -33,6 +34,7 @@ export const providers = () =>
33
34
  Provider.collection([
34
35
  ApiToken.AccountApiToken,
35
36
  ApiToken.UserApiToken,
37
+ Artifacts.ArtifactsBindingPolicy,
36
38
  Command,
37
39
  Containers.Container,
38
40
  D1.D1ConnectionPolicy,
@@ -58,6 +60,7 @@ export const providers = () =>
58
60
  Layer.mergeAll(
59
61
  ApiToken.AccountApiTokenProvider(),
60
62
  ApiToken.UserApiTokenProvider(),
63
+ Artifacts.ArtifactsBindingPolicyLive,
61
64
  Containers.ContainerProvider(),
62
65
  D1.D1ConnectionPolicyLive,
63
66
  D1.DatabaseProvider(),
@@ -1,5 +1,4 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
- // import type * as cf from "@cloudflare/workers-types";
3
2
 
4
3
  import type * as Effect from "effect/Effect";
5
4
  import type { UnwrapEffect } from "../../Util/effect.ts";
@@ -24,6 +23,8 @@ type GetBindingType<T> = T extends Cloudflare.Assets
24
23
  ? KVNamespace
25
24
  : T extends Cloudflare.Queue
26
25
  ? Queue<unknown>
27
- : T extends Cloudflare.DurableObjectNamespaceLike
28
- ? DurableObjectNamespace<Exclude<T["Shape"], undefined>>
29
- : never;
26
+ : T extends Cloudflare.Artifacts
27
+ ? Artifacts
28
+ : T extends Cloudflare.DurableObjectNamespaceLike
29
+ ? DurableObjectNamespace<Exclude<T["Shape"], undefined>>
30
+ : never;
@@ -24,6 +24,10 @@ import Sonda from "sonda/rolldown";
24
24
  import type * as vite from "vite";
25
25
  import * as Artifacts from "../../Artifacts.ts";
26
26
  import * as Binding from "../../Binding.ts";
27
+ import {
28
+ isArtifacts as isArtifactsBinding,
29
+ type Artifacts as ArtifactsBinding,
30
+ } from "../Artifacts/Artifacts.ts";
27
31
  import { hashDirectory, type MemoOptions } from "../../Build/Memo.ts";
28
32
  import * as Bundle from "../../Bundle/Bundle.ts";
29
33
  import { findCwdForBundle } from "../../Bundle/TempRoot.ts";
@@ -183,6 +187,7 @@ export type WorkerBindingResource =
183
187
  | D1Database
184
188
  | KVNamespace
185
189
  | CloudflareQueue
190
+ | ArtifactsBinding
186
191
  | DurableObjectNamespaceLike<any>;
187
192
 
188
193
  export type WorkerBindings = {
@@ -667,7 +672,13 @@ export const Worker: Platform<
667
672
  type: "assets",
668
673
  name: bindingName,
669
674
  }
670
- : isDurableObjectNamespaceLike(binding)
675
+ : isArtifactsBinding(binding)
676
+ ? ({
677
+ type: "artifacts",
678
+ name: bindingName,
679
+ namespace: binding.namespace,
680
+ } as any)
681
+ : isDurableObjectNamespaceLike(binding)
671
682
  ? {
672
683
  type: "durable_object_namespace",
673
684
  name: bindingName,
@@ -1,4 +1,5 @@
1
1
  export * from "./ApiToken/index.ts";
2
+ export * from "./Artifacts/index.ts";
2
3
  export * from "./CloudflareEnvironment.ts";
3
4
  export * from "./Container/index.ts";
4
5
  export * from "./D1/index.ts";
@@ -0,0 +1,309 @@
1
+ import * as Console from "effect/Console";
2
+ import * as Effect from "effect/Effect";
3
+ import * as FileSystem from "effect/FileSystem";
4
+ import * as Match from "effect/Match";
5
+ import * as Redacted from "effect/Redacted";
6
+ import * as Stream from "effect/Stream";
7
+ import * as ChildProcess from "effect/unstable/process/ChildProcess";
8
+ import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
9
+ import {
10
+ AuthError,
11
+ AuthProviderLayer,
12
+ type ConfigureContext,
13
+ } from "../Auth/AuthProvider.ts";
14
+ import {
15
+ deleteCredentials,
16
+ displayRedacted,
17
+ readCredentials,
18
+ writeCredentials,
19
+ } from "../Auth/Credentials.ts";
20
+ import { getEnvRedacted, retryOnce } from "../Auth/Env.ts";
21
+ import * as Clank from "../Util/Clank.ts";
22
+
23
+ const options: Array<{
24
+ value: GitHubAuthConfig["method"];
25
+ label: string;
26
+ hint?: string;
27
+ }> = [
28
+ {
29
+ value: "gh-cli",
30
+ label: "GitHub CLI",
31
+ hint: "delegate to `gh auth token` (run `gh auth login` first)",
32
+ },
33
+ {
34
+ value: "env",
35
+ label: "Environment Variables",
36
+ hint: "GITHUB_ACCESS_TOKEN or GITHUB_TOKEN",
37
+ },
38
+ {
39
+ value: "stored",
40
+ label: "Personal Access Token",
41
+ hint: "enter PAT interactively, stored in ~/.alchemy/credentials",
42
+ },
43
+ ];
44
+
45
+ export type GitHubAuthConfig =
46
+ | { method: "env" }
47
+ | { method: "stored" }
48
+ | { method: "gh-cli" };
49
+
50
+ export interface GitHubStoredCredentials {
51
+ type: "pat";
52
+ token: string;
53
+ }
54
+
55
+ export interface GitHubResolvedCredentials {
56
+ type: "token";
57
+ token: Redacted.Redacted<string>;
58
+ source: { type: GitHubAuthConfig["method"]; details?: string };
59
+ }
60
+
61
+ export const GITHUB_AUTH_PROVIDER_NAME = "GitHub";
62
+
63
+ /**
64
+ * Layer that registers the GitHub {@link AuthProvider} into the
65
+ * {@link AuthProviders} registry when built. Include this in the GitHub
66
+ * `providers()` layer so `alchemy login` can discover it.
67
+ *
68
+ * Supported methods:
69
+ * - `gh-cli`: shells out to `gh auth token` (recommended).
70
+ * - `env`: reads `GITHUB_ACCESS_TOKEN` or `GITHUB_TOKEN`.
71
+ * - `stored`: prompts for a PAT and writes it to `~/.alchemy/credentials`.
72
+ *
73
+ * Browser/device OAuth is intentionally not implemented: GitHub's
74
+ * OAuth App flow requires a `client_secret` we cannot ship, and
75
+ * device flow is exactly what `gh auth login` already does.
76
+ */
77
+ export const GitHubAuth = AuthProviderLayer<
78
+ GitHubAuthConfig,
79
+ GitHubResolvedCredentials
80
+ >()(GITHUB_AUTH_PROVIDER_NAME, {
81
+ configure: (profileName, ctx) => configureCredentials(profileName, ctx),
82
+ logout: (profileName, config) => logout(profileName, config),
83
+ login: (profileName, config) => login(profileName, config),
84
+ prettyPrint: (profileName, config) => prettyPrint(profileName, config),
85
+ read: (profileName, config) => resolveCredentials(profileName, config),
86
+ });
87
+
88
+ const resolveCredentials = (
89
+ profileName: string,
90
+ config: GitHubAuthConfig,
91
+ ): Effect.Effect<
92
+ GitHubResolvedCredentials,
93
+ AuthError,
94
+ FileSystem.FileSystem | ChildProcessSpawner
95
+ > =>
96
+ Match.value(config).pipe(
97
+ Match.when(
98
+ { method: "env" },
99
+ Effect.fnUntraced(function* () {
100
+ const access = yield* getEnvRedacted("GITHUB_ACCESS_TOKEN");
101
+ if (access) {
102
+ return {
103
+ type: "token" as const,
104
+ token: access,
105
+ source: {
106
+ type: "env" as const,
107
+ details: "GITHUB_ACCESS_TOKEN",
108
+ },
109
+ };
110
+ }
111
+ const token = yield* getEnvRedacted("GITHUB_TOKEN");
112
+ if (token) {
113
+ return {
114
+ type: "token" as const,
115
+ token,
116
+ source: { type: "env" as const, details: "GITHUB_TOKEN" },
117
+ };
118
+ }
119
+ return yield* new AuthError({
120
+ message:
121
+ "GitHub env credentials not found. Set GITHUB_ACCESS_TOKEN or GITHUB_TOKEN.",
122
+ });
123
+ }),
124
+ ),
125
+ Match.when({ method: "stored" }, () =>
126
+ readCredentials<GitHubStoredCredentials>(profileName, "gh-stored").pipe(
127
+ Effect.flatMap((creds) =>
128
+ creds == null
129
+ ? Effect.fail(
130
+ new AuthError({
131
+ message:
132
+ "GitHub stored credentials not found. Run: alchemy login --configure",
133
+ }),
134
+ )
135
+ : Effect.succeed({
136
+ type: "token" as const,
137
+ token: Redacted.make(creds.token),
138
+ source: { type: "stored" as const },
139
+ }),
140
+ ),
141
+ ),
142
+ ),
143
+ Match.when({ method: "gh-cli" }, () =>
144
+ ghCliToken().pipe(
145
+ Effect.map((token) => ({
146
+ type: "token" as const,
147
+ token: Redacted.make(token),
148
+ source: { type: "gh-cli" as const },
149
+ })),
150
+ ),
151
+ ),
152
+ Match.exhaustive,
153
+ );
154
+
155
+ const logout = (profileName: string, config: GitHubAuthConfig) =>
156
+ Match.value(config).pipe(
157
+ Match.when({ method: "env" }, () => Effect.void),
158
+ Match.when({ method: "gh-cli" }, () => Effect.void),
159
+ Match.when({ method: "stored" }, () =>
160
+ deleteCredentials(profileName, "gh-stored").pipe(
161
+ Effect.andThen(Clank.success("GitHub: stored credentials removed")),
162
+ ),
163
+ ),
164
+ Match.exhaustive,
165
+ );
166
+
167
+ const login = (profileName: string, config: GitHubAuthConfig) =>
168
+ Match.value(config)
169
+ .pipe(
170
+ Match.when({ method: "env" }, () => Effect.void),
171
+ Match.when({ method: "gh-cli" }, () =>
172
+ ghCliToken().pipe(
173
+ Effect.tap(() =>
174
+ Clank.success("GitHub: gh CLI authentication available."),
175
+ ),
176
+ Effect.asVoid,
177
+ ),
178
+ ),
179
+ Match.when({ method: "stored" }, () =>
180
+ readCredentials<GitHubStoredCredentials>(profileName, "gh-stored").pipe(
181
+ Effect.flatMap((creds) =>
182
+ creds == null ? loginStored(profileName) : Effect.void,
183
+ ),
184
+ ),
185
+ ),
186
+ Match.exhaustive,
187
+ )
188
+ .pipe(
189
+ Effect.mapError(
190
+ (e) => new AuthError({ message: "login failed", cause: e }),
191
+ ),
192
+ );
193
+
194
+ const configureCredentials = (profileName: string, ctx: ConfigureContext) =>
195
+ Effect.gen(function* () {
196
+ if (ctx.ci) {
197
+ return { method: "env" as const };
198
+ }
199
+ return yield* configureInteractive(profileName);
200
+ }).pipe(
201
+ Effect.mapError(
202
+ (e) =>
203
+ new AuthError({
204
+ message: "failed to configure credentials",
205
+ cause: e,
206
+ }),
207
+ ),
208
+ );
209
+
210
+ const configureInteractive = (profileName: string) =>
211
+ Clank.select({
212
+ message: "GitHub authentication method",
213
+ options,
214
+ }).pipe(
215
+ Effect.flatMap((method) =>
216
+ Match.value(method).pipe(
217
+ Match.when("env", () => Effect.succeed({ method: "env" as const })),
218
+ Match.when("gh-cli", () =>
219
+ ghCliToken().pipe(
220
+ Effect.as({ method: "gh-cli" as const }),
221
+ Effect.mapError(
222
+ (e) =>
223
+ new AuthError({
224
+ message: `gh CLI not available: ${e.message}`,
225
+ cause: e,
226
+ }),
227
+ ),
228
+ ),
229
+ ),
230
+ Match.when("stored", () => loginStored(profileName)),
231
+ Match.exhaustive,
232
+ ),
233
+ ),
234
+ );
235
+
236
+ const loginStored = Effect.fnUntraced(function* (profileName: string) {
237
+ const token = yield* Clank.password({
238
+ message:
239
+ "GitHub Personal Access Token (needs `repo` scope; `workflow` for Actions)",
240
+ validate: (v) => (v.length === 0 ? "Required" : undefined),
241
+ }).pipe(retryOnce);
242
+
243
+ yield* writeCredentials<GitHubStoredCredentials>(profileName, "gh-stored", {
244
+ type: "pat",
245
+ token,
246
+ });
247
+ yield* Clank.success("GitHub: credentials saved.");
248
+ return { method: "stored" as const };
249
+ });
250
+
251
+ class GhCliError extends Error {
252
+ readonly _tag = "GhCliError";
253
+ }
254
+
255
+ const ghCliToken = () =>
256
+ Effect.gen(function* () {
257
+ const handle = yield* ChildProcess.make("gh", ["auth", "token"], {
258
+ shell: false,
259
+ });
260
+ const [exitCode, stdout, stderr] = yield* Effect.all(
261
+ [
262
+ handle.exitCode,
263
+ Stream.mkString(Stream.decodeText(handle.stdout)),
264
+ Stream.mkString(Stream.decodeText(handle.stderr)),
265
+ ],
266
+ { concurrency: 3 },
267
+ );
268
+ if (exitCode !== 0) {
269
+ return yield* Effect.fail(
270
+ new GhCliError(
271
+ `gh auth token exited with ${exitCode}: ${stderr.trim() || stdout.trim()}`,
272
+ ),
273
+ );
274
+ }
275
+ const token = stdout.trim();
276
+ if (!token) {
277
+ return yield* Effect.fail(
278
+ new GhCliError("gh auth token returned empty output"),
279
+ );
280
+ }
281
+ return token;
282
+ }).pipe(
283
+ Effect.scoped,
284
+ Effect.mapError((e) =>
285
+ e instanceof GhCliError
286
+ ? new AuthError({ message: e.message, cause: e })
287
+ : new AuthError({
288
+ message:
289
+ "Could not invoke `gh`. Install GitHub CLI from https://cli.github.com/ and run `gh auth login`.",
290
+ cause: e,
291
+ }),
292
+ ),
293
+ );
294
+
295
+ const prettyPrint = (profileName: string, config: GitHubAuthConfig) =>
296
+ resolveCredentials(profileName, config).pipe(
297
+ Effect.tap((creds) => {
298
+ const sourceStr = creds.source.details
299
+ ? `${creds.source.type} - ${creds.source.details}`
300
+ : creds.source.type;
301
+ return Effect.all([
302
+ Console.log(` token: ${displayRedacted(creds.token, 6)}`),
303
+ Console.log(` source: ${sourceStr}`),
304
+ ]);
305
+ }),
306
+ Effect.catch((e) =>
307
+ Console.error(` Failed to retrieve credentials: ${e}`),
308
+ ),
309
+ );
@@ -0,0 +1,99 @@
1
+ import { Octokit } from "@octokit/rest";
2
+ import * as Config from "effect/Config";
3
+ import * as Context from "effect/Context";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Layer from "effect/Layer";
6
+ import * as Redacted from "effect/Redacted";
7
+ import { AuthError, getAuthProvider } from "../Auth/AuthProvider.ts";
8
+ import { ALCHEMY_PROFILE, loadOrConfigure } from "../Auth/Profile.ts";
9
+ import {
10
+ GITHUB_AUTH_PROVIDER_NAME,
11
+ type GitHubAuthConfig,
12
+ type GitHubResolvedCredentials,
13
+ } from "./AuthProvider.ts";
14
+
15
+ export interface GitHubCredentialsService {
16
+ readonly token: Redacted.Redacted<string>;
17
+ readonly octokit: () => Octokit;
18
+ }
19
+
20
+ export class GitHubCredentials extends Context.Service<
21
+ GitHubCredentials,
22
+ GitHubCredentialsService
23
+ >()("GitHub::Credentials") {}
24
+
25
+ const make = (token: Redacted.Redacted<string>): GitHubCredentialsService => ({
26
+ token,
27
+ octokit: () => new Octokit({ auth: Redacted.value(token) }),
28
+ });
29
+
30
+ /**
31
+ * Build a `GitHubCredentials` layer from a literal token. Useful for
32
+ * tests or when callers already have a PAT in hand.
33
+ */
34
+ export const fromToken = (token: string | Redacted.Redacted<string>) =>
35
+ Layer.succeed(
36
+ GitHubCredentials,
37
+ make(typeof token === "string" ? Redacted.make(token) : token),
38
+ );
39
+
40
+ /**
41
+ * Build a `GitHubCredentials` layer that reads the token from
42
+ * `GITHUB_ACCESS_TOKEN` or `GITHUB_TOKEN` at layer build time.
43
+ */
44
+ export const fromEnv = () =>
45
+ Layer.effect(
46
+ GitHubCredentials,
47
+ Effect.gen(function* () {
48
+ const access = yield* Config.redacted("GITHUB_ACCESS_TOKEN").pipe(
49
+ Config.option,
50
+ );
51
+ const token = yield* Config.redacted("GITHUB_TOKEN").pipe(Config.option);
52
+ const value =
53
+ access._tag === "Some"
54
+ ? access.value
55
+ : token._tag === "Some"
56
+ ? token.value
57
+ : undefined;
58
+ if (value == null) {
59
+ return yield* new AuthError({
60
+ message:
61
+ "GitHub credentials not found. Set GITHUB_ACCESS_TOKEN or GITHUB_TOKEN.",
62
+ });
63
+ }
64
+ return make(value);
65
+ }).pipe(Effect.orDie),
66
+ );
67
+
68
+ /**
69
+ * Build a `GitHubCredentials` layer that resolves a token via the
70
+ * Alchemy AuthProvider for the configured profile (defaults to
71
+ * `default`, overridable with `ALCHEMY_PROFILE`).
72
+ */
73
+ export const fromAuthProvider = () =>
74
+ Layer.effect(
75
+ GitHubCredentials,
76
+ Effect.gen(function* () {
77
+ const auth = yield* getAuthProvider<
78
+ GitHubAuthConfig,
79
+ GitHubResolvedCredentials
80
+ >(GITHUB_AUTH_PROVIDER_NAME);
81
+ const profileName = yield* ALCHEMY_PROFILE;
82
+ const ci = yield* Config.boolean("CI").pipe(Config.withDefault(false));
83
+ const ctx = yield* Effect.context<never>();
84
+
85
+ return yield* loadOrConfigure(auth, profileName, { ci }).pipe(
86
+ Effect.flatMap((config) =>
87
+ auth.read(profileName, config as GitHubAuthConfig),
88
+ ),
89
+ Effect.map((creds) => make(creds.token)),
90
+ Effect.mapError(
91
+ (e) =>
92
+ new AuthError({
93
+ message: `Failed to resolve GitHub credentials for profile '${profileName}': ${(e as { message?: string }).message ?? String(e)}`,
94
+ }),
95
+ ),
96
+ Effect.provide(ctx),
97
+ );
98
+ }).pipe(Effect.orDie),
99
+ );