alchemy 0.2.2 → 0.2.4
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/lib/ai/ark.d.ts +11 -0
- package/lib/ai/ark.js +86 -0
- package/lib/ai/client.d.ts +51 -0
- package/lib/ai/client.js +29 -0
- package/lib/ai/document.d.ts +97 -0
- package/lib/ai/document.js +82 -0
- package/lib/ai/index.d.ts +2 -0
- package/lib/ai/index.js +2 -0
- package/lib/ai/object.d.ts +101 -0
- package/lib/ai/object.js +70 -0
- package/lib/alchemy.d.ts +84 -2
- package/lib/alchemy.js +114 -10
- package/lib/apply.d.ts +1 -1
- package/lib/apply.js +13 -10
- package/lib/aws/bucket.d.ts +87 -1
- package/lib/aws/bucket.js +48 -1
- package/lib/aws/function.d.ts +186 -1
- package/lib/aws/function.js +62 -8
- package/lib/aws/oidc/github-oidc-provider.d.ts +81 -0
- package/lib/aws/oidc/github-oidc-provider.js +68 -0
- package/lib/aws/oidc/oidc-provider.d.ts +14 -6
- package/lib/aws/oidc/oidc-provider.js +26 -5
- package/lib/aws/policy-attachment.d.ts +44 -1
- package/lib/aws/policy-attachment.js +31 -0
- package/lib/aws/policy.d.ts +160 -1
- package/lib/aws/policy.js +78 -0
- package/lib/aws/queue.d.ts +83 -1
- package/lib/aws/queue.js +39 -0
- package/lib/aws/role.d.ts +162 -1
- package/lib/aws/role.js +114 -0
- package/lib/aws/ses.d.ts +57 -2
- package/lib/aws/ses.js +42 -1
- package/lib/aws/table.d.ts +96 -1
- package/lib/aws/table.js +36 -0
- package/lib/cloudflare/asset-manifest.js +0 -1
- package/lib/cloudflare/bound.js +0 -1
- package/lib/cloudflare/bucket.d.ts +37 -1
- package/lib/cloudflare/bucket.js +36 -0
- package/lib/cloudflare/durable-object-namespace.d.ts +25 -0
- package/lib/cloudflare/durable-object-namespace.js +22 -0
- package/lib/cloudflare/kv-namespace.d.ts +37 -1
- package/lib/cloudflare/kv-namespace.js +36 -0
- package/lib/cloudflare/static-site.d.ts +53 -1
- package/lib/cloudflare/static-site.js +53 -1
- package/lib/cloudflare/worker-metadata.js +0 -1
- package/lib/cloudflare/worker.d.ts +64 -1
- package/lib/cloudflare/worker.js +63 -0
- package/lib/cloudflare/wrangler.json.d.ts +1 -1
- package/lib/cloudflare/zone-settings.js +0 -1
- package/lib/cloudflare/zone.d.ts +56 -1
- package/lib/cloudflare/zone.js +55 -0
- package/lib/context.d.ts +9 -4
- package/lib/context.js +4 -2
- package/lib/destroy.js +9 -8
- package/lib/esbuild/bundle.d.ts +43 -4
- package/lib/esbuild/bundle.js +16 -0
- package/lib/fs/file-collection.d.ts +16 -0
- package/lib/fs/file-collection.js +6 -0
- package/lib/fs/file-ref.d.ts +14 -0
- package/lib/fs/file-ref.js +6 -0
- package/lib/fs/file.d.ts +78 -5
- package/lib/fs/file.js +39 -3
- package/lib/fs/folder.d.ts +37 -5
- package/lib/fs/folder.js +28 -3
- package/lib/fs/index.d.ts +6 -0
- package/lib/fs/index.js +6 -0
- package/lib/fs/json-file.d.ts +16 -0
- package/lib/fs/json-file.js +7 -0
- package/lib/fs/text-file.d.ts +12 -0
- package/lib/fs/text-file.js +7 -0
- package/lib/fs/typescript-file.d.ts +19 -0
- package/lib/fs/typescript-file.js +14 -0
- package/lib/fs/yaml-file.d.ts +19 -0
- package/lib/fs/yaml-file.js +8 -0
- package/lib/github/secret.d.ts +63 -2
- package/lib/github/secret.js +61 -1
- package/lib/internal/docs.d.ts +5 -0
- package/lib/internal/docs.js +324 -0
- package/lib/resource.d.ts +2 -2
- package/lib/resource.js +4 -0
- package/lib/scope.d.ts +1 -1
- package/lib/scope.js +6 -6
- package/lib/secret.d.ts +67 -0
- package/lib/secret.js +67 -0
- package/lib/shadcn/component.d.ts +1 -1
- package/lib/state.d.ts +1 -1
- package/lib/state.js +8 -1
- package/lib/stripe/price.d.ts +51 -1
- package/lib/stripe/price.js +50 -0
- package/lib/stripe/product.d.ts +39 -1
- package/lib/stripe/product.js +38 -0
- package/lib/stripe/webhook.d.ts +46 -1
- package/lib/stripe/webhook.js +45 -0
- package/lib/test/bun.d.ts +64 -0
- package/lib/test/bun.js +35 -3
- package/lib/util/serde.d.ts +3 -1
- package/lib/util/serde.js +20 -4
- package/lib/vite/vite.d.ts +1 -1
- package/lib/vitepress/dependencies.d.ts +12 -0
- package/lib/vitepress/dependencies.js +35 -0
- package/lib/vitepress/home-page.d.ts +133 -0
- package/lib/vitepress/home-page.js +11 -0
- package/lib/vitepress/index.d.ts +2 -0
- package/lib/vitepress/index.js +2 -0
- package/lib/vitepress/vitepress.d.ts +71 -0
- package/lib/vitepress/vitepress.js +266 -0
- package/package.json +22 -10
- package/src/ai/ark.ts +118 -0
- package/src/ai/client.ts +78 -0
- package/src/ai/document.ts +160 -0
- package/src/ai/index.ts +2 -0
- package/src/ai/object.ts +149 -0
- package/src/alchemy.ts +192 -17
- package/src/apply.ts +25 -13
- package/src/aws/bucket.ts +111 -7
- package/src/aws/function.ts +231 -10
- package/src/aws/oidc/github-oidc-provider.ts +83 -0
- package/src/aws/oidc/oidc-provider.ts +39 -7
- package/src/aws/policy-attachment.ts +44 -1
- package/src/aws/policy.ts +177 -2
- package/src/aws/queue.ts +89 -0
- package/src/aws/role.ts +174 -2
- package/src/aws/ses.ts +56 -1
- package/src/aws/table.ts +103 -0
- package/src/cloudflare/bucket.ts +36 -0
- package/src/cloudflare/durable-object-namespace.ts +25 -0
- package/src/cloudflare/kv-namespace.ts +36 -0
- package/src/cloudflare/static-site.ts +53 -1
- package/src/cloudflare/worker.ts +63 -0
- package/src/cloudflare/zone.ts +55 -0
- package/src/context.ts +30 -10
- package/src/destroy.ts +11 -8
- package/src/esbuild/bundle.ts +53 -3
- package/src/fs/file-collection.ts +24 -0
- package/src/fs/file-ref.ts +22 -0
- package/src/fs/file.ts +107 -6
- package/src/fs/folder.ts +45 -5
- package/src/fs/index.ts +6 -0
- package/src/fs/json-file.ts +23 -0
- package/src/fs/text-file.ts +19 -0
- package/src/fs/typescript-file.ts +36 -0
- package/src/fs/yaml-file.ts +26 -0
- package/src/github/secret.ts +65 -2
- package/src/internal/docs.ts +369 -0
- package/src/resource.ts +7 -2
- package/src/scope.ts +9 -9
- package/src/secret.ts +67 -0
- package/src/state.ts +9 -2
- package/src/stripe/price.ts +50 -0
- package/src/stripe/product.ts +38 -0
- package/src/stripe/webhook.ts +45 -0
- package/src/test/bun.ts +83 -3
- package/src/util/serde.ts +30 -4
- package/src/vitepress/dependencies.ts +68 -0
- package/src/vitepress/home-page.ts +166 -0
- package/src/vitepress/index.md +75 -0
- package/src/vitepress/index.ts +2 -0
- package/src/vitepress/vitepress.ts +382 -0
- package/lib/project/vite.d.ts +0 -90
- package/lib/project/vite.js +0 -228
- package/src/project/vite.ts +0 -406
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { generateText } from "ai";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { Context } from "../context";
|
|
5
|
+
import { Resource } from "../resource";
|
|
6
|
+
import type { Secret } from "../secret";
|
|
7
|
+
import {
|
|
8
|
+
type ModelConfig,
|
|
9
|
+
createClient,
|
|
10
|
+
getModelId,
|
|
11
|
+
getModelOptions,
|
|
12
|
+
} from "./client";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Properties for creating or updating a Document
|
|
16
|
+
*/
|
|
17
|
+
export interface DocumentProps {
|
|
18
|
+
/**
|
|
19
|
+
* Path to the markdown document
|
|
20
|
+
*/
|
|
21
|
+
path: string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Base URL for the OpenAI API
|
|
25
|
+
* @default 'https://api.openai.com/v1'
|
|
26
|
+
*/
|
|
27
|
+
baseURL?: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Prompt for generating content
|
|
31
|
+
* Use alchemy template literals to include file context:
|
|
32
|
+
* @example
|
|
33
|
+
* prompt: await alchemy`
|
|
34
|
+
* Generate docs using:
|
|
35
|
+
* ${alchemy.file("src/api.ts")}
|
|
36
|
+
* `
|
|
37
|
+
*/
|
|
38
|
+
prompt: string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* OpenAI API key to use for generating content
|
|
42
|
+
* If not provided, will use OPENAI_API_KEY environment variable
|
|
43
|
+
*/
|
|
44
|
+
apiKey?: Secret;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Model configuration
|
|
48
|
+
*/
|
|
49
|
+
model?: ModelConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A markdown document that can be created, updated, and deleted
|
|
54
|
+
*/
|
|
55
|
+
export interface Document extends DocumentProps, Resource<"docs::Document"> {
|
|
56
|
+
/**
|
|
57
|
+
* Content of the document
|
|
58
|
+
*/
|
|
59
|
+
content: string;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Time at which the document was created
|
|
63
|
+
*/
|
|
64
|
+
createdAt: number;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Time at which the document was last updated
|
|
68
|
+
*/
|
|
69
|
+
updatedAt: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resource for managing AI-generated markdown documents using the Vercel AI SDK.
|
|
74
|
+
* Supports powerful context handling through the alchemy template literal tag.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* // Create a document using alchemy template literals for context
|
|
78
|
+
* const apiDocs = await Document("api-docs", {
|
|
79
|
+
* path: "./docs/api.md",
|
|
80
|
+
* prompt: await alchemy`
|
|
81
|
+
* Generate API documentation based on these source files:
|
|
82
|
+
* ${alchemy.file("src/api.ts")}
|
|
83
|
+
* ${alchemy.file("src/types.ts")}
|
|
84
|
+
* `
|
|
85
|
+
* // The above will automatically append the file contents as code blocks:
|
|
86
|
+
* //
|
|
87
|
+
* // Generate API documentation based on these source files:
|
|
88
|
+
* // [api.ts](src/api.ts)
|
|
89
|
+
* // [types.ts](src/types.ts)
|
|
90
|
+
* //
|
|
91
|
+
* // // src/api.ts
|
|
92
|
+
* // ```ts
|
|
93
|
+
* // ... contents of api.ts ...
|
|
94
|
+
* // ```
|
|
95
|
+
* //
|
|
96
|
+
* // // src/types.ts
|
|
97
|
+
* // ```ts
|
|
98
|
+
* // ... contents of types.ts ...
|
|
99
|
+
* // ```
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* // Use alchemy template literals with file collections
|
|
104
|
+
* const modelDocs = await Document("models", {
|
|
105
|
+
* path: "./docs/models.md",
|
|
106
|
+
* prompt: await alchemy`
|
|
107
|
+
* Write documentation for these data models:
|
|
108
|
+
* ${alchemy.files("src/models/user.ts", "src/models/post.ts")}
|
|
109
|
+
* `
|
|
110
|
+
* // This creates a prompt with all files appended as code blocks,
|
|
111
|
+
* // automatically handling syntax highlighting based on file extensions
|
|
112
|
+
* });
|
|
113
|
+
*/
|
|
114
|
+
export const Document = Resource(
|
|
115
|
+
"docs::Document",
|
|
116
|
+
async function (
|
|
117
|
+
this: Context<Document>,
|
|
118
|
+
id: string,
|
|
119
|
+
props: DocumentProps,
|
|
120
|
+
): Promise<Document> {
|
|
121
|
+
// Ensure directory exists
|
|
122
|
+
await fs.mkdir(path.dirname(props.path), { recursive: true });
|
|
123
|
+
|
|
124
|
+
if (this.phase === "delete") {
|
|
125
|
+
try {
|
|
126
|
+
await fs.unlink(props.path);
|
|
127
|
+
} catch (error: any) {
|
|
128
|
+
// Ignore if file doesn't exist
|
|
129
|
+
if (error.code !== "ENOENT") {
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return this.destroy();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Initialize OpenAI compatible provider using shared client
|
|
137
|
+
const provider = createClient(props);
|
|
138
|
+
|
|
139
|
+
// Generate content
|
|
140
|
+
const { text } = await generateText({
|
|
141
|
+
model: provider(getModelId(props)),
|
|
142
|
+
prompt: props.prompt,
|
|
143
|
+
...getModelOptions(props),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Write content to file
|
|
147
|
+
await fs.writeFile(props.path, text);
|
|
148
|
+
|
|
149
|
+
// Get file stats for timestamps
|
|
150
|
+
const stats = await fs.stat(props.path);
|
|
151
|
+
|
|
152
|
+
// Return the resource
|
|
153
|
+
return this({
|
|
154
|
+
...props,
|
|
155
|
+
content: text,
|
|
156
|
+
createdAt: stats.birthtimeMs,
|
|
157
|
+
updatedAt: stats.mtimeMs,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
);
|
package/src/ai/index.ts
ADDED
package/src/ai/object.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { generateObject } from "ai";
|
|
2
|
+
import type { JsonSchema, Type, type } from "arktype";
|
|
3
|
+
import type { Context } from "../context";
|
|
4
|
+
import { Resource } from "../resource";
|
|
5
|
+
import type { Secret } from "../secret";
|
|
6
|
+
import { ark } from "./ark";
|
|
7
|
+
import {
|
|
8
|
+
type ModelConfig,
|
|
9
|
+
createClient,
|
|
10
|
+
getModelId,
|
|
11
|
+
getModelOptions,
|
|
12
|
+
} from "./client";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Properties for creating or updating an AI Object
|
|
16
|
+
*/
|
|
17
|
+
export interface ObjectProps<T extends Type<any, any>> {
|
|
18
|
+
/**
|
|
19
|
+
* The ArkType schema to validate and structure the generated content
|
|
20
|
+
*/
|
|
21
|
+
schema: T | JsonSchema;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Prompt for generating the content
|
|
25
|
+
* Use alchemy template literals to include file context:
|
|
26
|
+
* @example
|
|
27
|
+
* prompt: await alchemy`
|
|
28
|
+
* Generate a description for:
|
|
29
|
+
* ${alchemy.file("src/data.ts")}
|
|
30
|
+
* `
|
|
31
|
+
*/
|
|
32
|
+
prompt: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* System prompt to guide the AI's behavior
|
|
36
|
+
* @example
|
|
37
|
+
* system: "You are a technical writer tasked with describing code"
|
|
38
|
+
*/
|
|
39
|
+
system?: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Base URL for the OpenAI API
|
|
43
|
+
* @default 'https://api.openai.com/v1'
|
|
44
|
+
*/
|
|
45
|
+
baseURL?: string;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* OpenAI API key to use for generating content
|
|
49
|
+
* If not provided, will use OPENAI_API_KEY environment variable
|
|
50
|
+
*/
|
|
51
|
+
apiKey?: Secret;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Model configuration
|
|
55
|
+
*/
|
|
56
|
+
model?: ModelConfig;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A resource that uses AI to generate structured content based on a schema
|
|
61
|
+
*/
|
|
62
|
+
export interface Object<T> extends Resource<"ai::Object"> {
|
|
63
|
+
type: JsonSchema;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The generated content, typed according to the provided schema
|
|
67
|
+
*/
|
|
68
|
+
object: T;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Time at which the content was generated
|
|
72
|
+
*/
|
|
73
|
+
createdAt: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resource for generating structured content using the Vercel AI SDK.
|
|
78
|
+
* Supports powerful context handling through the alchemy template literal tag.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* // Generate a product description with specific fields
|
|
82
|
+
* const productSchema = type({
|
|
83
|
+
* name: "string",
|
|
84
|
+
* description: "string",
|
|
85
|
+
* features: "string[]",
|
|
86
|
+
* price: "number"
|
|
87
|
+
* });
|
|
88
|
+
*
|
|
89
|
+
* const product = await Object("new-product", {
|
|
90
|
+
* schema: productSchema,
|
|
91
|
+
* prompt: "Generate a product description for a new smartphone",
|
|
92
|
+
* system: "You are a product copywriter specializing in tech products"
|
|
93
|
+
* });
|
|
94
|
+
*
|
|
95
|
+
* console.log(product.content); // Typed as per schema
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* // Generate code documentation with context
|
|
99
|
+
* const docSchema = type({
|
|
100
|
+
* summary: "string",
|
|
101
|
+
* parameters: {
|
|
102
|
+
* name: "string",
|
|
103
|
+
* type: "string",
|
|
104
|
+
* description: "string"
|
|
105
|
+
* }[],
|
|
106
|
+
* returns: "string"
|
|
107
|
+
* });
|
|
108
|
+
*
|
|
109
|
+
* const docs = await Object("function-docs", {
|
|
110
|
+
* schema: docSchema,
|
|
111
|
+
* prompt: await alchemy`
|
|
112
|
+
* Generate documentation for this function:
|
|
113
|
+
* ${alchemy.file("src/utils/format.ts")}
|
|
114
|
+
* `,
|
|
115
|
+
* system: "You are a technical documentation writer"
|
|
116
|
+
* });
|
|
117
|
+
*/
|
|
118
|
+
export const Object = Resource("ai::Object", async function <
|
|
119
|
+
const T extends Type<any, any>,
|
|
120
|
+
>(this: Context<Object<any>>, id: string, props: ObjectProps<T>): Promise<
|
|
121
|
+
Object<type.infer<T>>
|
|
122
|
+
> {
|
|
123
|
+
if (this.phase === "delete") {
|
|
124
|
+
return this.destroy();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Initialize OpenAI compatible provider using shared client
|
|
128
|
+
const provider = createClient(props);
|
|
129
|
+
|
|
130
|
+
// Generate structured output using generateObject
|
|
131
|
+
const { object } = await generateObject({
|
|
132
|
+
model: provider(getModelId(props)),
|
|
133
|
+
// Convert ArkType schema to Zod schema for generateObject
|
|
134
|
+
// This is needed because generateObject expects a Zod schema
|
|
135
|
+
schema: ark.schema<type.infer<T>>(props.schema),
|
|
136
|
+
system:
|
|
137
|
+
props.system ||
|
|
138
|
+
"You are an AI assistant tasked with generating structured content.",
|
|
139
|
+
prompt: props.prompt,
|
|
140
|
+
...getModelOptions(props),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Return the resource with typed content
|
|
144
|
+
return this({
|
|
145
|
+
type: props.schema,
|
|
146
|
+
object: object,
|
|
147
|
+
createdAt: Date.now(),
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/alchemy.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
1
4
|
import { DestroyedSignal, destroy } from "./destroy";
|
|
2
5
|
import { Scope } from "./scope";
|
|
3
6
|
import { secret } from "./secret";
|
|
@@ -6,31 +9,175 @@ import type { StateStoreType } from "./state";
|
|
|
6
9
|
// TODO: support browser
|
|
7
10
|
const DEFAULT_STAGE = process.env.ALCHEMY_STAGE ?? process.env.USER ?? "dev";
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
appName,
|
|
13
|
-
stage: options.stage,
|
|
14
|
-
});
|
|
15
|
-
}
|
|
16
|
-
_alchemy.destroy = destroy;
|
|
17
|
-
_alchemy.run = run;
|
|
18
|
-
_alchemy.scope = scope;
|
|
19
|
-
_alchemy.secret = secret;
|
|
20
|
-
|
|
21
|
-
// alchemy type is to semantically highlight `alchemy` as a type (keyword)
|
|
12
|
+
/**
|
|
13
|
+
* Type alias for semantic highlighting of `alchemy` as a type keyword
|
|
14
|
+
*/
|
|
22
15
|
export type alchemy = Alchemy;
|
|
23
16
|
|
|
24
17
|
export const alchemy: Alchemy = _alchemy as any;
|
|
25
18
|
|
|
26
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The Alchemy interface provides core functionality and is augmented by providers.
|
|
21
|
+
* Supports both application scoping with secrets and template string interpolation.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // Create an application scope with stage and secret handling
|
|
25
|
+
* const app = alchemy("github:alchemy", {
|
|
26
|
+
* stage: "prod",
|
|
27
|
+
* phase: "up",
|
|
28
|
+
* // Required for encrypting/decrypting secrets
|
|
29
|
+
* password: process.env.SECRET_PASSPHRASE
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* // Create a resource with encrypted secrets
|
|
33
|
+
* const resource = await Resource("my-resource", {
|
|
34
|
+
* apiKey: alchemy.secret(process.env.API_KEY)
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* await app.finalize();
|
|
38
|
+
*/
|
|
27
39
|
export interface Alchemy {
|
|
28
40
|
scope: typeof scope;
|
|
29
41
|
run: typeof run;
|
|
30
42
|
destroy: typeof destroy;
|
|
43
|
+
/**
|
|
44
|
+
* Creates an encrypted secret that can be safely stored in state files.
|
|
45
|
+
* Requires a password to be set either globally in the application options
|
|
46
|
+
* or locally in the current scope.
|
|
47
|
+
*/
|
|
31
48
|
secret: typeof secret;
|
|
49
|
+
/**
|
|
50
|
+
* Creates a new application scope with the given name and options.
|
|
51
|
+
* Used to create and manage resources with proper secret handling.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* const app = alchemy("my-app", {
|
|
55
|
+
* stage: "prod",
|
|
56
|
+
* // Required for encrypting/decrypting secrets
|
|
57
|
+
* password: process.env.SECRET_PASSPHRASE
|
|
58
|
+
* });
|
|
59
|
+
*/
|
|
32
60
|
(...parameters: Parameters<typeof scope>): ReturnType<typeof scope>;
|
|
61
|
+
/**
|
|
62
|
+
* Template literal tag that supports file interpolation for documentation.
|
|
63
|
+
* Automatically formats the content and appends file contents as code blocks.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* // Generate documentation using file contents
|
|
67
|
+
* await Document("api-docs", {
|
|
68
|
+
* prompt: await alchemy`
|
|
69
|
+
* Generate docs using the contents of:
|
|
70
|
+
* ${alchemy.file("README.md")}
|
|
71
|
+
* ${alchemy.file("./.cursorrules")}
|
|
72
|
+
*
|
|
73
|
+
* And here are the source files:
|
|
74
|
+
* ${alchemy.files(files)}
|
|
75
|
+
* `
|
|
76
|
+
* });
|
|
77
|
+
*/
|
|
78
|
+
(template: TemplateStringsArray, ...values: any[]): Promise<string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Implementation of the alchemy function that handles both application scoping
|
|
83
|
+
* and template string interpolation.
|
|
84
|
+
*/
|
|
85
|
+
function _alchemy(
|
|
86
|
+
...args:
|
|
87
|
+
| [template: TemplateStringsArray, ...values: any[]]
|
|
88
|
+
| [appName: string, options?: Omit<AlchemyOptions, "appName">]
|
|
89
|
+
): any {
|
|
90
|
+
if (typeof args[0] === "string") {
|
|
91
|
+
const [appName, options] = args;
|
|
92
|
+
return scope(undefined, {
|
|
93
|
+
...options,
|
|
94
|
+
appName,
|
|
95
|
+
stage: options?.stage,
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
const [template, ...values] = args;
|
|
99
|
+
const [, secondLine] = template[0].split("\n");
|
|
100
|
+
const leadingSpaces = secondLine
|
|
101
|
+
? secondLine.match(/^(\s*)/)?.[1]?.length || 0
|
|
102
|
+
: 0;
|
|
103
|
+
const indent = " ".repeat(leadingSpaces);
|
|
104
|
+
|
|
105
|
+
return (async () => {
|
|
106
|
+
const { isFileCollection, isFileRef } = await import("./fs");
|
|
107
|
+
|
|
108
|
+
const appendices: Record<string, string> = {};
|
|
109
|
+
|
|
110
|
+
const stringValues = await Promise.all(
|
|
111
|
+
values.map(async function resolve(value): Promise<string> {
|
|
112
|
+
if (typeof value === "string") {
|
|
113
|
+
return indent + value;
|
|
114
|
+
} else if (value instanceof Promise) {
|
|
115
|
+
return resolve(await value);
|
|
116
|
+
} else if (isFileRef(value)) {
|
|
117
|
+
if (!(value.path in appendices)) {
|
|
118
|
+
appendices[value.path] = await fs.readFile(value.path, "utf-8");
|
|
119
|
+
}
|
|
120
|
+
return `[${path.basename(value.path)}](${value.path})`;
|
|
121
|
+
} else if (isFileCollection(value)) {
|
|
122
|
+
return Object.entries(value.files)
|
|
123
|
+
.map(([filePath, content]) => {
|
|
124
|
+
appendices[filePath] = content;
|
|
125
|
+
return `[${path.basename(filePath)}](${filePath})`;
|
|
126
|
+
})
|
|
127
|
+
.join("\n\n");
|
|
128
|
+
} else if (Array.isArray(value)) {
|
|
129
|
+
return (
|
|
130
|
+
await Promise.all(
|
|
131
|
+
value.map(async (value, i) => `${i}. ${await resolve(value)}`),
|
|
132
|
+
)
|
|
133
|
+
).join("\n");
|
|
134
|
+
} else {
|
|
135
|
+
// TODO: support other types
|
|
136
|
+
throw new Error(`Unsupported value type: ${JSON.stringify(value)}`);
|
|
137
|
+
}
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Construct the string template by joining template parts with interpolated values
|
|
142
|
+
const lines = template
|
|
143
|
+
.map((part) =>
|
|
144
|
+
part
|
|
145
|
+
.split("\n")
|
|
146
|
+
.map((line) =>
|
|
147
|
+
line.startsWith(indent) ? line.slice(indent.length) : line,
|
|
148
|
+
)
|
|
149
|
+
.join("\n"),
|
|
150
|
+
)
|
|
151
|
+
.flatMap((part, i) =>
|
|
152
|
+
i < stringValues.length ? [part, stringValues[i] ?? ""] : [part],
|
|
153
|
+
)
|
|
154
|
+
.join("")
|
|
155
|
+
.split("\n");
|
|
156
|
+
|
|
157
|
+
// Collect and sort appendices by file path
|
|
158
|
+
return [
|
|
159
|
+
// format the user prompt and trim the first line if it's empty
|
|
160
|
+
lines.length > 1 && lines[0].replaceAll(" ", "").length === 0
|
|
161
|
+
? lines.slice(1).join("\n")
|
|
162
|
+
: lines.join("\n"),
|
|
163
|
+
|
|
164
|
+
// sort appendices by path and include at the end of the prompt
|
|
165
|
+
Object.entries(appendices)
|
|
166
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
167
|
+
.map(([filePath, content]) => {
|
|
168
|
+
const extension = path.extname(filePath).slice(1);
|
|
169
|
+
const codeTag = extension ? extension : "";
|
|
170
|
+
return `// ${filePath}\n\`\`\`${codeTag}\n${content}\n\`\`\``;
|
|
171
|
+
})
|
|
172
|
+
.join("\n\n"),
|
|
173
|
+
].join("\n");
|
|
174
|
+
})();
|
|
175
|
+
}
|
|
33
176
|
}
|
|
177
|
+
_alchemy.destroy = destroy;
|
|
178
|
+
_alchemy.run = run;
|
|
179
|
+
_alchemy.scope = scope;
|
|
180
|
+
_alchemy.secret = secret;
|
|
34
181
|
|
|
35
182
|
export interface AlchemyOptions {
|
|
36
183
|
/**
|
|
@@ -72,14 +219,24 @@ export interface AlchemyOptions {
|
|
|
72
219
|
|
|
73
220
|
/**
|
|
74
221
|
* A passphrase to use to encrypt/decrypt secrets.
|
|
222
|
+
* Required if using alchemy.secret() in this scope.
|
|
75
223
|
*/
|
|
76
224
|
password?: string;
|
|
77
225
|
}
|
|
78
226
|
|
|
79
227
|
/**
|
|
80
228
|
* Enter a new scope synchronously.
|
|
81
|
-
*
|
|
82
|
-
* @
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* // Create a scope with a password for secret handling
|
|
232
|
+
* await using scope = alchemy.scope("my-scope", {
|
|
233
|
+
* password: process.env.SECRET_PASSPHRASE
|
|
234
|
+
* });
|
|
235
|
+
*
|
|
236
|
+
* // Use secrets within the scope
|
|
237
|
+
* const resource = await Resource("my-resource", {
|
|
238
|
+
* apiKey: alchemy.secret(process.env.API_KEY)
|
|
239
|
+
* });
|
|
83
240
|
*/
|
|
84
241
|
function scope(
|
|
85
242
|
id: string | undefined,
|
|
@@ -98,6 +255,21 @@ function scope(
|
|
|
98
255
|
return scope;
|
|
99
256
|
}
|
|
100
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Run a function in a new scope asynchronously.
|
|
260
|
+
* Useful for isolating secret handling with a specific password.
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* // Run operations in a scope with its own password
|
|
264
|
+
* await alchemy.run("secure-scope", {
|
|
265
|
+
* password: process.env.SCOPE_PASSWORD
|
|
266
|
+
* }, async () => {
|
|
267
|
+
* // Secrets in this scope will use this password
|
|
268
|
+
* const resource = await Resource("my-resource", {
|
|
269
|
+
* apiKey: alchemy.secret(process.env.API_KEY)
|
|
270
|
+
* });
|
|
271
|
+
* });
|
|
272
|
+
*/
|
|
101
273
|
async function run<T>(
|
|
102
274
|
...args:
|
|
103
275
|
| [id: string, fn: (this: Scope, scope: Scope) => Promise<T>]
|
|
@@ -115,13 +287,16 @@ async function run<T>(
|
|
|
115
287
|
AlchemyOptions | undefined,
|
|
116
288
|
(this: Scope, scope: Scope) => Promise<T>,
|
|
117
289
|
]);
|
|
118
|
-
|
|
290
|
+
const scope = alchemy.scope(id, options);
|
|
119
291
|
try {
|
|
120
292
|
return await fn.bind(scope)(scope);
|
|
121
293
|
} catch (error) {
|
|
122
294
|
if (!(error instanceof DestroyedSignal)) {
|
|
123
295
|
scope.fail();
|
|
296
|
+
} else {
|
|
124
297
|
}
|
|
125
298
|
throw error;
|
|
299
|
+
} finally {
|
|
300
|
+
await scope.finalize();
|
|
126
301
|
}
|
|
127
302
|
}
|
package/src/apply.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type Resource,
|
|
8
8
|
type ResourceProps,
|
|
9
9
|
} from "./resource";
|
|
10
|
+
import { Secret } from "./secret";
|
|
10
11
|
import type { State } from "./state";
|
|
11
12
|
import { serialize } from "./util/serde";
|
|
12
13
|
|
|
@@ -17,12 +18,12 @@ export interface ApplyOptions {
|
|
|
17
18
|
|
|
18
19
|
export async function apply<Out extends Resource>(
|
|
19
20
|
resource: PendingResource<Out>,
|
|
20
|
-
props: ResourceProps,
|
|
21
|
+
props: ResourceProps | undefined,
|
|
21
22
|
options?: ApplyOptions,
|
|
22
23
|
): Promise<Awaited<Out>> {
|
|
23
24
|
const scope = resource.Scope;
|
|
24
25
|
try {
|
|
25
|
-
const quiet = props
|
|
26
|
+
const quiet = props?.quiet ?? scope.quiet;
|
|
26
27
|
await scope.init();
|
|
27
28
|
let state: State | undefined = (await scope.state.get(resource.ID))!;
|
|
28
29
|
const provider: Provider = PROVIDERS.get(resource.Kind);
|
|
@@ -55,17 +56,27 @@ export async function apply<Out extends Resource>(
|
|
|
55
56
|
|
|
56
57
|
// Skip update if inputs haven't changed and resource is in a stable state
|
|
57
58
|
if (state.status === "created" || state.status === "updated") {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
const oldProps = JSON.stringify(
|
|
60
|
+
state.props,
|
|
61
|
+
(_, value) =>
|
|
62
|
+
value instanceof Secret
|
|
63
|
+
? {
|
|
64
|
+
"@secret": value.unencrypted,
|
|
65
|
+
}
|
|
66
|
+
: value,
|
|
67
|
+
2,
|
|
68
|
+
);
|
|
69
|
+
const newProps = JSON.stringify(
|
|
70
|
+
await serialize(scope, props, {
|
|
71
|
+
encrypt: false,
|
|
72
|
+
}),
|
|
73
|
+
null,
|
|
74
|
+
2,
|
|
75
|
+
);
|
|
76
|
+
if (oldProps === newProps && alwaysUpdate !== true) {
|
|
63
77
|
if (!quiet) {
|
|
64
78
|
console.log(`Skip: "${resource.FQN}" (no changes)`);
|
|
65
79
|
}
|
|
66
|
-
// if (resourceState.output !== undefined) {
|
|
67
|
-
// resource[Provide](resourceState.output);
|
|
68
|
-
// }
|
|
69
80
|
return state.output as Awaited<Out>;
|
|
70
81
|
}
|
|
71
82
|
}
|
|
@@ -92,6 +103,7 @@ export async function apply<Out extends Resource>(
|
|
|
92
103
|
id: resource.ID,
|
|
93
104
|
fqn: resource.FQN,
|
|
94
105
|
seq: resource.Seq,
|
|
106
|
+
props: state.oldProps,
|
|
95
107
|
state,
|
|
96
108
|
replace: () => {
|
|
97
109
|
if (isReplaced) {
|
|
@@ -104,9 +116,9 @@ export async function apply<Out extends Resource>(
|
|
|
104
116
|
},
|
|
105
117
|
});
|
|
106
118
|
|
|
107
|
-
const output = await alchemy.run(resource.ID, async () =>
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
const output = await alchemy.run(resource.ID, async () =>
|
|
120
|
+
provider.handler.bind(ctx)(resource.ID, props),
|
|
121
|
+
);
|
|
110
122
|
|
|
111
123
|
if (!quiet) {
|
|
112
124
|
console.log(
|