alchemy 0.93.4 → 0.93.7
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/bin/alchemy.js +1966 -1825
- package/bin/alchemy.ts +2 -0
- package/bin/commands/state.ts +58 -0
- package/bin/services/execute-alchemy.ts +9 -1
- package/lib/alchemy.d.ts.map +1 -1
- package/lib/alchemy.js +7 -0
- package/lib/alchemy.js.map +1 -1
- package/lib/cloudflare/access-application.d.ts +204 -0
- package/lib/cloudflare/access-application.d.ts.map +1 -0
- package/lib/cloudflare/access-application.js +205 -0
- package/lib/cloudflare/access-application.js.map +1 -0
- package/lib/cloudflare/access-group.d.ts +115 -0
- package/lib/cloudflare/access-group.d.ts.map +1 -0
- package/lib/cloudflare/access-group.js +147 -0
- package/lib/cloudflare/access-group.js.map +1 -0
- package/lib/cloudflare/access-identity-provider.d.ts +261 -0
- package/lib/cloudflare/access-identity-provider.d.ts.map +1 -0
- package/lib/cloudflare/access-identity-provider.js +229 -0
- package/lib/cloudflare/access-identity-provider.js.map +1 -0
- package/lib/cloudflare/access-policy.d.ts +168 -0
- package/lib/cloudflare/access-policy.d.ts.map +1 -0
- package/lib/cloudflare/access-policy.js +176 -0
- package/lib/cloudflare/access-policy.js.map +1 -0
- package/lib/cloudflare/access-rule.d.ts +248 -0
- package/lib/cloudflare/access-rule.d.ts.map +1 -0
- package/lib/cloudflare/access-rule.js +37 -0
- package/lib/cloudflare/access-rule.js.map +1 -0
- package/lib/cloudflare/access-service-token.d.ts +86 -0
- package/lib/cloudflare/access-service-token.d.ts.map +1 -0
- package/lib/cloudflare/access-service-token.js +127 -0
- package/lib/cloudflare/access-service-token.js.map +1 -0
- package/lib/cloudflare/index.d.ts +6 -0
- package/lib/cloudflare/index.d.ts.map +1 -1
- package/lib/cloudflare/index.js +6 -0
- package/lib/cloudflare/index.js.map +1 -1
- package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -1
- package/lib/cloudflare/miniflare/build-worker-options.js +17 -3
- package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -1
- package/lib/state/file-system-state-store.d.ts +1 -0
- package/lib/state/file-system-state-store.d.ts.map +1 -1
- package/lib/state/file-system-state-store.js +16 -0
- package/lib/state/file-system-state-store.js.map +1 -1
- package/lib/state/operations.d.ts +1 -0
- package/lib/state/operations.d.ts.map +1 -1
- package/lib/state/operations.js +24 -0
- package/lib/state/operations.js.map +1 -1
- package/lib/state/proxy.d.ts +1 -0
- package/lib/state/proxy.d.ts.map +1 -1
- package/lib/state/proxy.js +3 -0
- package/lib/state/proxy.js.map +1 -1
- package/lib/state-cli.d.ts +9 -0
- package/lib/state-cli.d.ts.map +1 -0
- package/lib/state-cli.js +110 -0
- package/lib/state-cli.js.map +1 -0
- package/lib/state.d.ts +2 -0
- package/lib/state.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/alchemy.ts +9 -0
- package/src/cloudflare/access-application.ts +489 -0
- package/src/cloudflare/access-group.ts +271 -0
- package/src/cloudflare/access-identity-provider.ts +584 -0
- package/src/cloudflare/access-policy.ts +392 -0
- package/src/cloudflare/access-rule.ts +307 -0
- package/src/cloudflare/access-service-token.ts +255 -0
- package/src/cloudflare/index.ts +6 -0
- package/src/cloudflare/miniflare/build-worker-options.ts +20 -3
- package/src/state/file-system-state-store.ts +16 -0
- package/src/state/operations.ts +22 -0
- package/src/state/proxy.ts +4 -0
- package/src/state-cli.ts +134 -0
- package/src/state.ts +2 -0
- package/workers/cloudflare-state-store.js +17 -0
- package/workers/tunnel-proxy.js +1 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { alchemy } from "../alchemy.ts";
|
|
2
|
+
import type { Context } from "../context.ts";
|
|
3
|
+
import { Resource, ResourceKind } from "../resource.ts";
|
|
4
|
+
import type { Secret } from "../secret.ts";
|
|
5
|
+
import { logger } from "../util/logger.ts";
|
|
6
|
+
import { isCloudflareApiError } from "./api-error.ts";
|
|
7
|
+
import {
|
|
8
|
+
extractCloudflareResult,
|
|
9
|
+
type CloudflareApiListResponse,
|
|
10
|
+
} from "./api-response.ts";
|
|
11
|
+
import {
|
|
12
|
+
createCloudflareApi,
|
|
13
|
+
type CloudflareApi,
|
|
14
|
+
type CloudflareApiOptions,
|
|
15
|
+
} from "./api.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Properties for creating or updating an {@link AccessServiceToken}.
|
|
19
|
+
*/
|
|
20
|
+
export interface AccessServiceTokenProps extends CloudflareApiOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Display name of the service token.
|
|
23
|
+
*
|
|
24
|
+
* @default ${app}-${stage}-${id}
|
|
25
|
+
*/
|
|
26
|
+
name?: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How long the token is valid for. Format: a Cloudflare duration string
|
|
30
|
+
* such as `"24h"`, `"30d"`, or `"8760h"` (one year).
|
|
31
|
+
*
|
|
32
|
+
* @default "8760h"
|
|
33
|
+
*/
|
|
34
|
+
duration?: string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Adopt an existing service token with the same name instead of failing
|
|
38
|
+
* with a duplicate-name error.
|
|
39
|
+
*
|
|
40
|
+
* Note: when adopting, the `clientSecret` cannot be recovered — the output
|
|
41
|
+
* `clientSecret` will be `undefined`. Recreate the resource (or rotate via
|
|
42
|
+
* the Cloudflare dashboard) if you need a new secret.
|
|
43
|
+
*
|
|
44
|
+
* @default false
|
|
45
|
+
*/
|
|
46
|
+
adopt?: boolean;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether to delete the token when removed from Alchemy.
|
|
50
|
+
*
|
|
51
|
+
* @default true
|
|
52
|
+
*/
|
|
53
|
+
delete?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Output for an {@link AccessServiceToken}.
|
|
58
|
+
*/
|
|
59
|
+
export type AccessServiceToken = Omit<
|
|
60
|
+
AccessServiceTokenProps,
|
|
61
|
+
"delete" | "adopt"
|
|
62
|
+
> & {
|
|
63
|
+
/** Cloudflare-assigned token UUID. */
|
|
64
|
+
id: string;
|
|
65
|
+
/** Display name of the token. */
|
|
66
|
+
name: string;
|
|
67
|
+
/**
|
|
68
|
+
* Value sent in the `CF-Access-Client-Id` header when authenticating.
|
|
69
|
+
*/
|
|
70
|
+
clientId: string;
|
|
71
|
+
/**
|
|
72
|
+
* Value sent in the `CF-Access-Client-Secret` header when authenticating.
|
|
73
|
+
* **Returned only on creation** — `undefined` for adopted tokens.
|
|
74
|
+
*/
|
|
75
|
+
clientSecret?: Secret;
|
|
76
|
+
/** ISO 8601 creation timestamp. */
|
|
77
|
+
createdAt: string;
|
|
78
|
+
/** ISO 8601 last-update timestamp. */
|
|
79
|
+
updatedAt: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Type guard for {@link AccessServiceToken}.
|
|
84
|
+
*/
|
|
85
|
+
export function isAccessServiceToken(
|
|
86
|
+
resource: any,
|
|
87
|
+
): resource is AccessServiceToken {
|
|
88
|
+
return resource?.[ResourceKind] === "cloudflare::AccessServiceToken";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface CloudflareAccessServiceToken {
|
|
92
|
+
id: string;
|
|
93
|
+
name: string;
|
|
94
|
+
client_id: string;
|
|
95
|
+
client_secret?: string;
|
|
96
|
+
duration?: string;
|
|
97
|
+
created_at: string;
|
|
98
|
+
updated_at: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Creates a Cloudflare Zero Trust [Access service token](https://developers.cloudflare.com/cloudflare-one/identity/service-tokens/)
|
|
103
|
+
* for machine-to-machine authentication against Access-protected applications.
|
|
104
|
+
*
|
|
105
|
+
* The returned `clientSecret` is only available on creation — store it
|
|
106
|
+
* immediately. Adopted tokens have `clientSecret: undefined`.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* // Basic service token (defaults to 1 year duration)
|
|
110
|
+
* const token = await AccessServiceToken("ci-token", {
|
|
111
|
+
* name: "ci-runner",
|
|
112
|
+
* });
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* // Custom duration
|
|
116
|
+
* const shortLived = await AccessServiceToken("preview", {
|
|
117
|
+
* name: "preview-deploy-token",
|
|
118
|
+
* duration: "720h", // 30 days
|
|
119
|
+
* });
|
|
120
|
+
*/
|
|
121
|
+
export const AccessServiceToken = Resource(
|
|
122
|
+
"cloudflare::AccessServiceToken",
|
|
123
|
+
async function (
|
|
124
|
+
this: Context<AccessServiceToken>,
|
|
125
|
+
id: string,
|
|
126
|
+
props: AccessServiceTokenProps,
|
|
127
|
+
): Promise<AccessServiceToken> {
|
|
128
|
+
const api = await createCloudflareApi(props);
|
|
129
|
+
const name = props.name ?? this.scope.createPhysicalName(id);
|
|
130
|
+
const basePath = `/accounts/${api.accountId}/access/service_tokens`;
|
|
131
|
+
|
|
132
|
+
if (this.phase === "delete") {
|
|
133
|
+
if (this.output?.id && props.delete !== false) {
|
|
134
|
+
await deleteAccessServiceToken(api, this.output.id);
|
|
135
|
+
}
|
|
136
|
+
return this.destroy();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const body: Record<string, unknown> = { name };
|
|
140
|
+
if (props.duration) body.duration = props.duration;
|
|
141
|
+
|
|
142
|
+
let token: CloudflareAccessServiceToken;
|
|
143
|
+
if (this.phase === "update" && this.output?.id) {
|
|
144
|
+
token = await extractCloudflareResult<CloudflareAccessServiceToken>(
|
|
145
|
+
`update access service token "${name}"`,
|
|
146
|
+
api.put(`${basePath}/${this.output.id}`, body),
|
|
147
|
+
);
|
|
148
|
+
} else {
|
|
149
|
+
const adopt = props.adopt ?? this.scope.adopt;
|
|
150
|
+
try {
|
|
151
|
+
token = await extractCloudflareResult<CloudflareAccessServiceToken>(
|
|
152
|
+
`create access service token "${name}"`,
|
|
153
|
+
api.post(basePath, body),
|
|
154
|
+
);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (adopt && isAccessDuplicateNameError(err)) {
|
|
157
|
+
const existing = await findAccessServiceTokenByName(api, name);
|
|
158
|
+
if (!existing) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Service token "${name}" already exists but could not be found for adoption.`,
|
|
161
|
+
{ cause: err },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
logger.log(
|
|
165
|
+
`Adopting existing access service token "${name}" (${existing.id})`,
|
|
166
|
+
);
|
|
167
|
+
token = await extractCloudflareResult<CloudflareAccessServiceToken>(
|
|
168
|
+
`adopt access service token "${name}"`,
|
|
169
|
+
api.put(`${basePath}/${existing.id}`, body),
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// clientSecret is only returned on creation; retain on subsequent updates.
|
|
178
|
+
const clientSecret = token.client_secret
|
|
179
|
+
? alchemy.secret(token.client_secret)
|
|
180
|
+
: this.output?.clientSecret;
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
id: token.id,
|
|
184
|
+
name: token.name,
|
|
185
|
+
clientId: token.client_id,
|
|
186
|
+
clientSecret,
|
|
187
|
+
duration: token.duration,
|
|
188
|
+
createdAt: token.created_at,
|
|
189
|
+
updatedAt: token.updated_at,
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Cloudflare returns error code 12132 ("Access service token already exists")
|
|
196
|
+
* for duplicate names.
|
|
197
|
+
*/
|
|
198
|
+
function isAccessDuplicateNameError(err: unknown): boolean {
|
|
199
|
+
if (
|
|
200
|
+
isCloudflareApiError(err, { status: 409 }) ||
|
|
201
|
+
isCloudflareApiError(err, { status: 400 })
|
|
202
|
+
) {
|
|
203
|
+
const data = err.errorData;
|
|
204
|
+
if (
|
|
205
|
+
Array.isArray(data) &&
|
|
206
|
+
data.some(
|
|
207
|
+
(e) => "message" in e && /already exists/i.test(String(e.message)),
|
|
208
|
+
)
|
|
209
|
+
) {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Look up an existing service token by name across paginated results.
|
|
218
|
+
*/
|
|
219
|
+
async function findAccessServiceTokenByName(
|
|
220
|
+
api: CloudflareApi,
|
|
221
|
+
name: string,
|
|
222
|
+
): Promise<CloudflareAccessServiceToken | null> {
|
|
223
|
+
let page = 1;
|
|
224
|
+
const perPage = 50;
|
|
225
|
+
while (true) {
|
|
226
|
+
const response = await api.get(
|
|
227
|
+
`/accounts/${api.accountId}/access/service_tokens?page=${page}&per_page=${perPage}`,
|
|
228
|
+
);
|
|
229
|
+
if (!response.ok) return null;
|
|
230
|
+
const data =
|
|
231
|
+
(await response.json()) as CloudflareApiListResponse<CloudflareAccessServiceToken>;
|
|
232
|
+
const match = data.result.find((t) => t.name === name);
|
|
233
|
+
if (match) return match;
|
|
234
|
+
const info = data.result_info;
|
|
235
|
+
if (!info || info.page * info.per_page >= info.total_count) return null;
|
|
236
|
+
page++;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Delete a service token. No-op on 404.
|
|
242
|
+
*/
|
|
243
|
+
async function deleteAccessServiceToken(
|
|
244
|
+
api: CloudflareApi,
|
|
245
|
+
tokenId: string,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
const response = await api.delete(
|
|
248
|
+
`/accounts/${api.accountId}/access/service_tokens/${tokenId}`,
|
|
249
|
+
);
|
|
250
|
+
if (!response.ok && response.status !== 404) {
|
|
251
|
+
logger.error(
|
|
252
|
+
`Error deleting access service token ${tokenId}: ${response.status} ${response.statusText}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
package/src/cloudflare/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
2
|
|
|
3
|
+
export * from "./access-application.ts";
|
|
4
|
+
export * from "./access-group.ts";
|
|
5
|
+
export * from "./access-identity-provider.ts";
|
|
6
|
+
export * from "./access-policy.ts";
|
|
7
|
+
export * from "./access-rule.ts";
|
|
8
|
+
export * from "./access-service-token.ts";
|
|
3
9
|
export * from "./account-api-token.ts";
|
|
4
10
|
export * from "./account-id.ts";
|
|
5
11
|
export * from "./ai-crawler.ts";
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as miniflare from "miniflare";
|
|
2
2
|
import path from "pathe";
|
|
3
|
+
import { DockerApi } from "../../docker/api.ts";
|
|
3
4
|
import { assertNever } from "../../util/assert-never.ts";
|
|
4
5
|
import { reservePort } from "../../util/find-open-port.ts";
|
|
5
6
|
import type { HTTPServer } from "../../util/http.ts";
|
|
6
7
|
import { logger } from "../../util/logger.ts";
|
|
8
|
+
import { memoize } from "../../util/memoize.ts";
|
|
7
9
|
import { isAiSearchNamespace } from "../ai-search-namespace.ts";
|
|
8
10
|
import { isAiSearch } from "../ai-search.ts";
|
|
9
11
|
import type { CloudflareApi } from "../api.ts";
|
|
@@ -18,6 +20,11 @@ import type { WorkerBundle, WorkerBundleSource } from "../worker-bundle.ts";
|
|
|
18
20
|
import type { AssetsConfig } from "../worker.ts";
|
|
19
21
|
import { createRemoteProxyWorker } from "./remote-binding-proxy.ts";
|
|
20
22
|
|
|
23
|
+
// Copied from @cloudflare/containers-shared:
|
|
24
|
+
// https://github.com/cloudflare/workers-sdk/blob/fc2d8838b3c5395d5d248e8073a393f7dbb9536d/packages/containers-shared/src/images.ts#L20
|
|
25
|
+
const CONTAINER_EGRESS_INTERCEPTOR_IMAGE =
|
|
26
|
+
"cloudflare/proxy-everything:3cb1195@sha256:0ef6716c52430096900b150d84a3302057d6cd2319dae7987128c85d0733e3c8";
|
|
27
|
+
|
|
21
28
|
export interface MiniflareWorkerInput {
|
|
22
29
|
api: CloudflareApi;
|
|
23
30
|
id: string;
|
|
@@ -189,9 +196,7 @@ export const buildWorkerOptions = async (
|
|
|
189
196
|
},
|
|
190
197
|
};
|
|
191
198
|
options.containerEngine = {
|
|
192
|
-
localDocker:
|
|
193
|
-
socketPath: await getDockerSocketPath(),
|
|
194
|
-
},
|
|
199
|
+
localDocker: await getLocalDocker(),
|
|
195
200
|
};
|
|
196
201
|
break;
|
|
197
202
|
}
|
|
@@ -632,6 +637,18 @@ const isRemoteBinding = (binding: Binding) => {
|
|
|
632
637
|
);
|
|
633
638
|
};
|
|
634
639
|
|
|
640
|
+
const getLocalDocker = memoize(async () => {
|
|
641
|
+
const docker = new DockerApi();
|
|
642
|
+
const [socketPath] = await Promise.all([
|
|
643
|
+
getDockerSocketPath(),
|
|
644
|
+
docker.pullImage(CONTAINER_EGRESS_INTERCEPTOR_IMAGE),
|
|
645
|
+
]);
|
|
646
|
+
return {
|
|
647
|
+
socketPath,
|
|
648
|
+
containerEgressInterceptorImage: CONTAINER_EGRESS_INTERCEPTOR_IMAGE,
|
|
649
|
+
};
|
|
650
|
+
});
|
|
651
|
+
|
|
635
652
|
/**
|
|
636
653
|
* DOCKER_HOST env is standardized
|
|
637
654
|
* docker has an option to expose on tcp://localhost:2375; so we check 2375 if the user has it enabled
|
|
@@ -106,6 +106,22 @@ export class FileSystemStateStore implements StateStore {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
async listScopes(): Promise<string[]> {
|
|
110
|
+
try {
|
|
111
|
+
const entries = await fs.promises.readdir(this.dir, {
|
|
112
|
+
withFileTypes: true,
|
|
113
|
+
});
|
|
114
|
+
return entries
|
|
115
|
+
.filter((dirent) => dirent.isDirectory())
|
|
116
|
+
.map((dirent) => dirent.name);
|
|
117
|
+
} catch (error: any) {
|
|
118
|
+
if (error.code === "ENOENT") {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
109
125
|
async all(): Promise<Record<string, State>> {
|
|
110
126
|
return this.getBatch(await this.list());
|
|
111
127
|
}
|
package/src/state/operations.ts
CHANGED
|
@@ -30,6 +30,8 @@ export class SQLiteStateStoreOperations implements StateStore {
|
|
|
30
30
|
return;
|
|
31
31
|
case "list":
|
|
32
32
|
return this.list();
|
|
33
|
+
case "listScopes":
|
|
34
|
+
return this.listScopes();
|
|
33
35
|
case "count":
|
|
34
36
|
return this.count();
|
|
35
37
|
case "get": {
|
|
@@ -62,6 +64,26 @@ export class SQLiteStateStoreOperations implements StateStore {
|
|
|
62
64
|
.where(eq(schema.resources.scope, this.context.chain));
|
|
63
65
|
return result.map((r) => r.id);
|
|
64
66
|
}
|
|
67
|
+
async listScopes() {
|
|
68
|
+
const rows = await this.db
|
|
69
|
+
.selectDistinct({ scope: schema.resources.scope })
|
|
70
|
+
.from(schema.resources);
|
|
71
|
+
const prefix = this.context.chain;
|
|
72
|
+
const names = new Set<string>();
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
const scope = row.scope as string[];
|
|
75
|
+
if (scope.length !== prefix.length + 1) continue;
|
|
76
|
+
let matches = true;
|
|
77
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
78
|
+
if (scope[i] !== prefix[i]) {
|
|
79
|
+
matches = false;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (matches) names.add(scope[prefix.length]!);
|
|
84
|
+
}
|
|
85
|
+
return [...names];
|
|
86
|
+
}
|
|
65
87
|
async count() {
|
|
66
88
|
const result = await this.db.$count(
|
|
67
89
|
this.db
|
package/src/state/proxy.ts
CHANGED
package/src/state-cli.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Scope } from "./scope.ts";
|
|
2
|
+
import type { State } from "./state.ts";
|
|
3
|
+
|
|
4
|
+
export type StateCommand = "tree" | "list" | "get";
|
|
5
|
+
|
|
6
|
+
interface StateNode {
|
|
7
|
+
resources: string[];
|
|
8
|
+
children: Record<string, StateNode>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function walk(scope: Scope): Promise<StateNode> {
|
|
12
|
+
const resources = await scope.state.list();
|
|
13
|
+
const children: Record<string, StateNode> = {};
|
|
14
|
+
if (typeof scope.state.listScopes === "function") {
|
|
15
|
+
const childNames = await scope.state.listScopes();
|
|
16
|
+
for (const name of childNames) {
|
|
17
|
+
const childScope = new Scope({ parent: scope, scopeName: name });
|
|
18
|
+
children[name] = await walk(childScope);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { resources, children };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderTree(rootName: string, node: StateNode): string {
|
|
25
|
+
const lines: string[] = [rootName];
|
|
26
|
+
appendNode(lines, node, "");
|
|
27
|
+
return lines.join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function appendNode(lines: string[], node: StateNode, indent: string) {
|
|
31
|
+
const childEntries = Object.entries(node.children).sort(([a], [b]) =>
|
|
32
|
+
a.localeCompare(b),
|
|
33
|
+
);
|
|
34
|
+
const resources = [...node.resources].sort();
|
|
35
|
+
const total = resources.length + childEntries.length;
|
|
36
|
+
let i = 0;
|
|
37
|
+
for (const r of resources) {
|
|
38
|
+
const last = i === total - 1;
|
|
39
|
+
lines.push(`${indent}${last ? "└─" : "├─"} ${r}`);
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
for (const [name, child] of childEntries) {
|
|
43
|
+
const last = i === total - 1;
|
|
44
|
+
lines.push(`${indent}${last ? "└─" : "├─"} ${name}`);
|
|
45
|
+
appendNode(lines, child, `${indent}${last ? " " : "│ "}`);
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function flatten(prefix: string[], node: StateNode, out: string[]) {
|
|
51
|
+
for (const r of [...node.resources].sort()) {
|
|
52
|
+
out.push([...prefix, r].join("/"));
|
|
53
|
+
}
|
|
54
|
+
for (const [name, child] of Object.entries(node.children).sort(([a], [b]) =>
|
|
55
|
+
a.localeCompare(b),
|
|
56
|
+
)) {
|
|
57
|
+
flatten([...prefix, name], child, out);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run an `alchemy state` subcommand against the configured state store
|
|
63
|
+
* and exit. Invoked by `alchemy.ts` when the user's program is launched
|
|
64
|
+
* via `alchemy state ...` — the user's resources are never created.
|
|
65
|
+
*/
|
|
66
|
+
export async function runStateCommand(
|
|
67
|
+
scope: Scope,
|
|
68
|
+
command: StateCommand,
|
|
69
|
+
arg?: string,
|
|
70
|
+
): Promise<never> {
|
|
71
|
+
if (command === "get") {
|
|
72
|
+
const fqn = arg;
|
|
73
|
+
if (!fqn) {
|
|
74
|
+
console.error("alchemy state get: missing resource fqn");
|
|
75
|
+
return process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const state = await findByFqn(scope, fqn);
|
|
78
|
+
if (state === undefined) {
|
|
79
|
+
console.log(`(not found: ${fqn})`);
|
|
80
|
+
return process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
console.log(JSON.stringify(state, replacer, 2));
|
|
83
|
+
return process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const node = await walk(scope);
|
|
87
|
+
|
|
88
|
+
if (command === "list") {
|
|
89
|
+
const out: string[] = [];
|
|
90
|
+
flatten(scope.chain, node, out);
|
|
91
|
+
if (out.length === 0) {
|
|
92
|
+
console.log("(no resources)");
|
|
93
|
+
} else {
|
|
94
|
+
for (const fqn of out) console.log(fqn);
|
|
95
|
+
}
|
|
96
|
+
return process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// tree
|
|
100
|
+
const rootName = scope.chain.join("/");
|
|
101
|
+
if (node.resources.length === 0 && Object.keys(node.children).length === 0) {
|
|
102
|
+
console.log(`${rootName}\n(empty)`);
|
|
103
|
+
return process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
console.log(renderTree(rootName, node));
|
|
106
|
+
return process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function findByFqn(
|
|
110
|
+
scope: Scope,
|
|
111
|
+
fqn: string,
|
|
112
|
+
): Promise<State | undefined> {
|
|
113
|
+
const parts = fqn.split("/").filter(Boolean);
|
|
114
|
+
// Strip leading chain (app/stage/...) if user passed full fqn
|
|
115
|
+
const chain = scope.chain;
|
|
116
|
+
let path = parts;
|
|
117
|
+
if (parts.length > chain.length && chain.every((c, i) => c === parts[i])) {
|
|
118
|
+
path = parts.slice(chain.length);
|
|
119
|
+
}
|
|
120
|
+
if (path.length === 0) return undefined;
|
|
121
|
+
let current: Scope = scope;
|
|
122
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
123
|
+
current = new Scope({ parent: current, scopeName: path[i]! });
|
|
124
|
+
}
|
|
125
|
+
return current.state.get(path[path.length - 1]!);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function replacer(_key: string, value: unknown) {
|
|
129
|
+
if (typeof value === "bigint") return value.toString();
|
|
130
|
+
if (value && typeof value === "object" && (value as any).type === "Buffer") {
|
|
131
|
+
return "[Buffer]";
|
|
132
|
+
}
|
|
133
|
+
return value;
|
|
134
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -40,4 +40,6 @@ export interface StateStore {
|
|
|
40
40
|
all(): Promise<Record<string, State>>;
|
|
41
41
|
set(key: string, value: State): Promise<void>;
|
|
42
42
|
delete(key: string): Promise<void>;
|
|
43
|
+
/** List names of nested scopes within this scope, if supported. */
|
|
44
|
+
listScopes?(): Promise<string[]>;
|
|
43
45
|
}
|
|
@@ -4693,6 +4693,7 @@ var SQLiteStateStoreOperations = class {
|
|
|
4693
4693
|
case "init": return;
|
|
4694
4694
|
case "deinit": return;
|
|
4695
4695
|
case "list": return this.list();
|
|
4696
|
+
case "listScopes": return this.listScopes();
|
|
4696
4697
|
case "count": return this.count();
|
|
4697
4698
|
case "get": {
|
|
4698
4699
|
const [key] = params;
|
|
@@ -4717,6 +4718,22 @@ var SQLiteStateStoreOperations = class {
|
|
|
4717
4718
|
async list() {
|
|
4718
4719
|
return (await this.db.select({ id: resources.id }).from(resources).where(eq(resources.scope, this.context.chain))).map((r) => r.id);
|
|
4719
4720
|
}
|
|
4721
|
+
async listScopes() {
|
|
4722
|
+
const rows = await this.db.selectDistinct({ scope: resources.scope }).from(resources);
|
|
4723
|
+
const prefix = this.context.chain;
|
|
4724
|
+
const names = /* @__PURE__ */ new Set();
|
|
4725
|
+
for (const row of rows) {
|
|
4726
|
+
const scope = row.scope;
|
|
4727
|
+
if (scope.length !== prefix.length + 1) continue;
|
|
4728
|
+
let matches = true;
|
|
4729
|
+
for (let i = 0; i < prefix.length; i++) if (scope[i] !== prefix[i]) {
|
|
4730
|
+
matches = false;
|
|
4731
|
+
break;
|
|
4732
|
+
}
|
|
4733
|
+
if (matches) names.add(scope[prefix.length]);
|
|
4734
|
+
}
|
|
4735
|
+
return [...names];
|
|
4736
|
+
}
|
|
4720
4737
|
async count() {
|
|
4721
4738
|
return await this.db.$count(this.db.select().from(resources).where(eq(resources.scope, this.context.chain)));
|
|
4722
4739
|
}
|
package/workers/tunnel-proxy.js
CHANGED
|
@@ -83,7 +83,7 @@ const renderErrorHtml = (props) => `
|
|
|
83
83
|
Alchemy</a>.</p>
|
|
84
84
|
</div>
|
|
85
85
|
<div class="bg-slate-200 px-5 py-3 flex flex-col w-full max-w-lg">
|
|
86
|
-
<p class="text-sm text-slate-500">Alchemy 0.93.
|
|
86
|
+
<p class="text-sm text-slate-500">Alchemy 0.93.7</p>
|
|
87
87
|
</div>
|
|
88
88
|
</div>
|
|
89
89
|
</body>
|