alchemy 0.2.2 → 0.2.3

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 (74) hide show
  1. package/lib/alchemy.d.ts +1 -0
  2. package/lib/alchemy.js +84 -8
  3. package/lib/apply.d.ts +1 -1
  4. package/lib/apply.js +13 -10
  5. package/lib/aws/bucket.d.ts +1 -1
  6. package/lib/aws/function.d.ts +1 -1
  7. package/lib/aws/oidc/oidc-provider.d.ts +1 -1
  8. package/lib/aws/policy-attachment.d.ts +1 -1
  9. package/lib/aws/policy.d.ts +1 -1
  10. package/lib/aws/queue.d.ts +1 -1
  11. package/lib/aws/role.d.ts +1 -1
  12. package/lib/aws/ses.d.ts +1 -1
  13. package/lib/aws/table.d.ts +1 -1
  14. package/lib/cloudflare/bucket.d.ts +1 -1
  15. package/lib/cloudflare/kv-namespace.d.ts +1 -1
  16. package/lib/cloudflare/static-site.d.ts +1 -1
  17. package/lib/cloudflare/worker.d.ts +1 -1
  18. package/lib/cloudflare/wrangler.json.d.ts +1 -1
  19. package/lib/cloudflare/zone.d.ts +1 -1
  20. package/lib/context.d.ts +9 -4
  21. package/lib/context.js +4 -2
  22. package/lib/destroy.js +8 -7
  23. package/lib/docs/document.d.ts +123 -0
  24. package/lib/docs/document.js +67 -0
  25. package/lib/docs/index.d.ts +1 -0
  26. package/lib/docs/index.js +1 -0
  27. package/lib/esbuild/bundle.d.ts +1 -1
  28. package/lib/fs/file.d.ts +30 -3
  29. package/lib/fs/file.js +63 -3
  30. package/lib/fs/folder.d.ts +3 -3
  31. package/lib/fs/folder.js +2 -1
  32. package/lib/github/secret.d.ts +1 -1
  33. package/lib/resource.d.ts +2 -2
  34. package/lib/resource.js +4 -0
  35. package/lib/scope.d.ts +1 -1
  36. package/lib/scope.js +6 -6
  37. package/lib/shadcn/component.d.ts +1 -1
  38. package/lib/state.d.ts +1 -1
  39. package/lib/state.js +8 -1
  40. package/lib/stripe/price.d.ts +1 -1
  41. package/lib/stripe/product.d.ts +1 -1
  42. package/lib/stripe/webhook.d.ts +1 -1
  43. package/lib/util/serde.d.ts +3 -1
  44. package/lib/util/serde.js +6 -4
  45. package/lib/vite/vite.d.ts +1 -1
  46. package/lib/vitepress/dependencies.d.ts +16 -0
  47. package/lib/vitepress/dependencies.js +35 -0
  48. package/lib/vitepress/home-page.d.ts +133 -0
  49. package/lib/vitepress/home-page.js +11 -0
  50. package/lib/vitepress/index.d.ts +2 -0
  51. package/lib/vitepress/index.js +2 -0
  52. package/lib/vitepress/vitepress.d.ts +60 -0
  53. package/lib/vitepress/vitepress.js +260 -0
  54. package/package.json +16 -9
  55. package/src/alchemy.ts +105 -13
  56. package/src/apply.ts +25 -13
  57. package/src/context.ts +30 -10
  58. package/src/destroy.ts +10 -7
  59. package/src/docs/document.ts +225 -0
  60. package/src/docs/index.ts +1 -0
  61. package/src/fs/file.ts +114 -6
  62. package/src/fs/folder.ts +2 -1
  63. package/src/resource.ts +7 -2
  64. package/src/scope.ts +9 -9
  65. package/src/state.ts +9 -2
  66. package/src/util/serde.ts +13 -4
  67. package/src/vitepress/dependencies.ts +68 -0
  68. package/src/vitepress/home-page.ts +166 -0
  69. package/src/vitepress/index.md +75 -0
  70. package/src/vitepress/index.ts +2 -0
  71. package/src/vitepress/vitepress.ts +361 -0
  72. package/lib/project/vite.d.ts +0 -90
  73. package/lib/project/vite.js +0 -228
  74. package/src/project/vite.ts +0 -406
@@ -0,0 +1,225 @@
1
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
2
+ import { generateText } from "ai";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import type { Context } from "../context";
6
+ import { Resource } from "../resource";
7
+ import type { Secret } from "../secret";
8
+
9
+ /**
10
+ * Base properties for a Document
11
+ */
12
+ interface BaseDocumentProps {
13
+ /**
14
+ * Path to the markdown document
15
+ */
16
+ path: string;
17
+
18
+ /**
19
+ * Base URL for the OpenAI API
20
+ * @default 'https://api.openai.com/v1'
21
+ */
22
+ baseURL?: string;
23
+ }
24
+
25
+ /**
26
+ * Properties for creating a document with static content
27
+ */
28
+ interface StaticDocumentProps extends BaseDocumentProps {
29
+ /**
30
+ * Static content of the document
31
+ */
32
+ content: string;
33
+
34
+ /**
35
+ * Prompt for generating content
36
+ * @internal This is marked as never to ensure content and prompt are mutually exclusive
37
+ */
38
+ prompt?: never;
39
+
40
+ /**
41
+ * OpenAI API key
42
+ * @internal This is marked as never since it's not needed for static content
43
+ */
44
+ apiKey?: never;
45
+
46
+ /**
47
+ * Model configuration
48
+ * @internal This is marked as never since it's not needed for static content
49
+ */
50
+ model?: never;
51
+
52
+ /**
53
+ * Context files
54
+ * @internal This is marked as never since it's not needed for static content
55
+ */
56
+ context?: never;
57
+ }
58
+
59
+ /**
60
+ * Properties for creating a document with AI-generated content
61
+ */
62
+ interface GeneratedDocumentProps extends BaseDocumentProps {
63
+ /**
64
+ * Static content of the document
65
+ * @internal This is marked as never to ensure content and prompt are mutually exclusive
66
+ */
67
+ content?: never;
68
+
69
+ /**
70
+ * Prompt for generating content
71
+ * Can be either a single string or an array of strings that will be joined with newlines
72
+ */
73
+ prompt: string | string[];
74
+
75
+ /**
76
+ * Optional context files to include in the prompt
77
+ * Key is the relative path to the file
78
+ * Value can be either the content string or another Document resource
79
+ */
80
+ context?: {
81
+ [relativePath: string]:
82
+ | string
83
+ | Document
84
+ | {
85
+ path: string;
86
+ content: string;
87
+ };
88
+ };
89
+
90
+ /**
91
+ * OpenAI API key to use for generating content
92
+ * If not provided, will use OPENAI_API_KEY environment variable
93
+ */
94
+ apiKey?: Secret;
95
+
96
+ /**
97
+ * Model configuration
98
+ */
99
+ model?: {
100
+ /**
101
+ * Model ID to use
102
+ * @default 'gpt-4o'
103
+ */
104
+ id?: string;
105
+
106
+ /**
107
+ * Model provider name
108
+ * @default 'openai'
109
+ */
110
+ provider?: string;
111
+
112
+ /**
113
+ * Model-specific options
114
+ */
115
+ options?: Record<string, unknown>;
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Properties for creating or updating a Document
121
+ * Must provide either static content or a prompt for AI generation
122
+ */
123
+ export type DocumentProps = StaticDocumentProps | GeneratedDocumentProps;
124
+
125
+ /**
126
+ * A markdown document that can be created, updated, and deleted
127
+ */
128
+ export interface Document
129
+ extends Omit<DocumentProps, "content" | "prompt" | "context">,
130
+ Resource<"docs::Document"> {
131
+ /**
132
+ * Content of the document
133
+ */
134
+ content: string;
135
+
136
+ /**
137
+ * Time at which the document was created
138
+ */
139
+ createdAt: number;
140
+
141
+ /**
142
+ * Time at which the document was last updated
143
+ */
144
+ updatedAt: number;
145
+ }
146
+
147
+ /**
148
+ * Resource for managing markdown documents using the Vercel AI SDK
149
+ */
150
+ export const Document = Resource(
151
+ "docs::Document",
152
+ async function (
153
+ this: Context<Document, DocumentProps>,
154
+ id: string,
155
+ props: DocumentProps,
156
+ ): Promise<Document> {
157
+ // Ensure directory exists
158
+ await fs.mkdir(path.dirname(props.path), { recursive: true });
159
+
160
+ if (this.phase === "delete") {
161
+ try {
162
+ await fs.unlink(props.path);
163
+ } catch (error: any) {
164
+ // Ignore if file doesn't exist
165
+ if (error.code !== "ENOENT") {
166
+ throw error;
167
+ }
168
+ }
169
+ return this.destroy();
170
+ }
171
+
172
+ let content: string;
173
+
174
+ console.log("Generating content for", props.path);
175
+
176
+ // Generate content if prompt is provided
177
+ if ("prompt" in props) {
178
+ // Get API key from props or environment
179
+ const apiKey = props.apiKey?.unencrypted || process.env.OPENAI_API_KEY;
180
+ if (!apiKey) {
181
+ throw new Error("OpenAI API key is required for content generation");
182
+ }
183
+
184
+ // Initialize OpenAI compatible provider
185
+ const provider = createOpenAICompatible({
186
+ name: props.model?.provider || "openai",
187
+ apiKey,
188
+ baseURL: props.baseURL || "https://api.openai.com/v1",
189
+ });
190
+
191
+ // Build the full prompt with context if provided
192
+ const fullPrompt = Array.isArray(props.prompt)
193
+ ? props.prompt.join("\n")
194
+ : props.prompt!;
195
+
196
+ // console.log(fullPrompt);
197
+
198
+ const { text } = await generateText({
199
+ model: provider(props.model?.id || "gpt-4o"),
200
+ prompt: fullPrompt,
201
+ ...(props.model?.options || {}),
202
+ });
203
+
204
+ content = text;
205
+ } else {
206
+ // Use static content
207
+ content = props.content;
208
+ }
209
+
210
+ // Write content to file
211
+ await fs.writeFile(props.path, content);
212
+
213
+ // Get file stats for timestamps
214
+ const stats = await fs.stat(props.path);
215
+
216
+ // Return the resource
217
+ return this({
218
+ path: props.path,
219
+ baseURL: props.baseURL,
220
+ content,
221
+ createdAt: stats.birthtimeMs,
222
+ updatedAt: stats.mtimeMs,
223
+ });
224
+ },
225
+ );
@@ -0,0 +1 @@
1
+ export * from "./document";
package/src/fs/file.ts CHANGED
@@ -4,6 +4,69 @@ import type { Context } from "../context";
4
4
  import { Resource } from "../resource";
5
5
  import { ignore } from "../util/ignore";
6
6
 
7
+ import { alchemy } from "../alchemy";
8
+
9
+ declare module "../alchemy" {
10
+ interface Alchemy {
11
+ file(path: string): Promise<FileRef>;
12
+ files(paths: string[]): Promise<FileCollection>;
13
+ files(path: string, ...paths: string[]): Promise<FileCollection>;
14
+ }
15
+ }
16
+
17
+ export type FileRef = {
18
+ kind: "fs::FileRef";
19
+ path: string;
20
+ };
21
+
22
+ export function isFileRef(value: unknown): value is FileRef {
23
+ return (
24
+ typeof value === "object" &&
25
+ value !== null &&
26
+ "kind" in value &&
27
+ value.kind === "fs::FileRef"
28
+ );
29
+ }
30
+
31
+ export type FileCollection = {
32
+ type: "fs::FileCollection";
33
+ files: {
34
+ [relativePath: string]: string;
35
+ };
36
+ };
37
+
38
+ export function isFileCollection(value: unknown): value is FileCollection {
39
+ return (
40
+ typeof value === "object" &&
41
+ value !== null &&
42
+ "type" in value &&
43
+ value.type === "fs::FileCollection"
44
+ );
45
+ }
46
+
47
+ alchemy.file = async (path: string) => ({
48
+ kind: "fs::FileRef",
49
+ path,
50
+ });
51
+
52
+ alchemy.files = async (
53
+ ...args: [paths: string[]] | [...paths: string[]]
54
+ ): Promise<FileCollection> => {
55
+ const paths: string[] =
56
+ typeof args[0] === "string" ? (args as string[]) : args[0];
57
+ return {
58
+ type: "fs::FileCollection",
59
+ files: Object.fromEntries(
60
+ await Promise.all(
61
+ paths.map(async (path) => [
62
+ path,
63
+ await fs.promises.readFile(path, "utf-8"),
64
+ ]),
65
+ ),
66
+ ),
67
+ };
68
+ };
69
+
7
70
  export interface File extends Resource<"fs::File"> {
8
71
  path: string;
9
72
  content: string;
@@ -14,14 +77,12 @@ export const File = Resource(
14
77
  async function (
15
78
  this: Context<File>,
16
79
  id: string,
17
- {
18
- path: filePath,
19
- content,
20
- }: {
80
+ props: {
21
81
  path: string;
22
82
  content: string;
23
83
  },
24
84
  ): Promise<File> {
85
+ const filePath = props?.path ?? id;
25
86
  if (this.phase === "delete") {
26
87
  await ignore("ENOENT", async () => fs.promises.unlink(filePath));
27
88
  return this.destroy();
@@ -29,11 +90,58 @@ export const File = Resource(
29
90
  await fs.promises.mkdir(path.dirname(filePath), {
30
91
  recursive: true,
31
92
  });
32
- await fs.promises.writeFile(filePath, content);
93
+ await fs.promises.writeFile(filePath, props.content);
33
94
  }
34
95
  return this({
35
96
  path: filePath,
36
- content,
97
+ content: props.content,
37
98
  });
38
99
  },
39
100
  );
101
+
102
+ export type JsonFile = File;
103
+
104
+ export function JsonFile(id: string, content: any): Promise<JsonFile> {
105
+ return File(id, {
106
+ path: id,
107
+ content: JSON.stringify(content, null, 2),
108
+ });
109
+ }
110
+
111
+ export type TextFile = File;
112
+
113
+ export function TextFile(id: string, content: string): Promise<TextFile> {
114
+ return File(id, {
115
+ path: id,
116
+ content,
117
+ });
118
+ }
119
+
120
+ export type YamlFile = File;
121
+
122
+ export async function YamlFile(id: string, content: any): Promise<YamlFile> {
123
+ const yaml = await import("yaml");
124
+ return File(id, {
125
+ path: id,
126
+ content: yaml.stringify(content),
127
+ });
128
+ }
129
+
130
+ export type TypeScriptFile = File;
131
+
132
+ export async function TypeScriptFile(
133
+ id: string,
134
+ content: string,
135
+ ): Promise<TypeScriptFile> {
136
+ const prettier = await import("prettier");
137
+ return File(id, {
138
+ path: id,
139
+ content: await prettier.format(content, {
140
+ parser: "typescript",
141
+ editor: {
142
+ tabWidth: 2,
143
+ indentWidth: 2,
144
+ },
145
+ }),
146
+ });
147
+ }
package/src/fs/folder.ts CHANGED
@@ -12,8 +12,9 @@ export const Folder = Resource(
12
12
  async function (
13
13
  this: Context<Folder>,
14
14
  id: string,
15
- { path: dirPath }: { path: string },
15
+ props?: { path: string },
16
16
  ): Promise<Folder> {
17
+ const dirPath = props?.path ?? id;
17
18
  if (this.phase === "delete") {
18
19
  // we just do a best effort attempt
19
20
  await ignore(["ENOENT", "ENOTEMPTY"], async () =>
package/src/resource.ts CHANGED
@@ -67,7 +67,7 @@ type IsClass = {
67
67
  };
68
68
 
69
69
  type ResourceLifecycleHandler = (
70
- this: Context<any>,
70
+ this: Context<any, any>,
71
71
  id: string,
72
72
  props: any,
73
73
  ) => Promise<Resource<string>>;
@@ -75,7 +75,7 @@ type ResourceLifecycleHandler = (
75
75
  // see: https://x.com/samgoodwin89/status/1904640134097887653
76
76
  type Handler<F extends (...args: any[]) => any> =
77
77
  | F
78
- | (((this: any, id: string, props: Parameters<F>[1]) => never) & IsClass);
78
+ | (((this: any, id: string, props?: Parameters<F>[1]) => never) & IsClass);
79
79
 
80
80
  export function Resource<
81
81
  const Type extends string,
@@ -104,6 +104,11 @@ export function Resource<
104
104
  ): Promise<Resource<string>> => {
105
105
  const scope = _Scope.current;
106
106
 
107
+ if (resourceID.includes(":")) {
108
+ // we want to use : as an internal separator for resources
109
+ throw new Error(`ID cannot include colons: ${resourceID}`);
110
+ }
111
+
107
112
  if (scope.resources.has(resourceID)) {
108
113
  // TODO(sam): do we want to throw?
109
114
  // it's kind of awesome that you can re-create a resource and call apply
package/src/scope.ts CHANGED
@@ -98,6 +98,14 @@ export class Scope {
98
98
  return [...this.chain, resourceID].join("/");
99
99
  }
100
100
 
101
+ public async run<T>(fn: (scope: Scope) => Promise<T>): Promise<T> {
102
+ return scopeStorage.run(this, () => fn(this));
103
+ }
104
+
105
+ [Symbol.asyncDispose]() {
106
+ return this.finalize();
107
+ }
108
+
101
109
  public async finalize() {
102
110
  if (!this.isErrored) {
103
111
  // TODO: need to detect if it is in error
@@ -118,18 +126,10 @@ export class Scope {
118
126
  }
119
127
  }
120
128
 
121
- public async run<T>(fn: (scope: Scope) => Promise<T>): Promise<T> {
122
- return scopeStorage.run(this, () => fn(this));
123
- }
124
-
125
- [Symbol.asyncDispose]() {
126
- return this.finalize();
127
- }
128
-
129
129
  /**
130
130
  * Returns a string representation of the scope.
131
131
  */
132
- toString() {
132
+ public toString() {
133
133
  return `Scope(
134
134
  chain=${this.chain.join("/")},
135
135
  resources=[${Array.from(this.resources.values())
package/src/state.ts CHANGED
@@ -7,7 +7,7 @@ import { deserialize, serialize } from "./util/serde";
7
7
 
8
8
  export interface State<
9
9
  Kind extends string = string,
10
- Props extends ResourceProps = ResourceProps,
10
+ Props extends ResourceProps | undefined = ResourceProps | undefined,
11
11
  Out extends Resource = Resource,
12
12
  > {
13
13
  status:
@@ -74,7 +74,8 @@ export class FileSystemStateStore implements StateStore {
74
74
  });
75
75
  return files
76
76
  .filter((dirent) => dirent.isFile() && dirent.name.endsWith(".json"))
77
- .map((dirent) => dirent.name.replace(/\.json$/, ""));
77
+ .map((dirent) => dirent.name.replace(/\.json$/, ""))
78
+ .map((key) => key.replaceAll(":", "/"));
78
79
  } catch (error: any) {
79
80
  if (error.code === "ENOENT") {
80
81
  return [];
@@ -138,6 +139,12 @@ export class FileSystemStateStore implements StateStore {
138
139
  }
139
140
 
140
141
  private async getPath(key: string): Promise<string> {
142
+ if (key.includes(":")) {
143
+ throw new Error(`ID cannot include colons: ${key}`);
144
+ }
145
+ if (key.includes("/")) {
146
+ key = key.replaceAll("/", ":");
147
+ }
141
148
  const file = path.join(this.dir, `${key}.json`);
142
149
  const dir = path.dirname(file);
143
150
  await fs.promises.mkdir(dir, { recursive: true });
package/src/util/serde.ts CHANGED
@@ -2,15 +2,24 @@ import { Scope } from "../scope";
2
2
  import { Secret } from "../secret";
3
3
  import { decryptWithKey, encryptWithKey } from "./encrypt";
4
4
 
5
- export async function serialize(scope: Scope, value: any): Promise<any> {
5
+ export async function serialize(
6
+ scope: Scope,
7
+ value: any,
8
+ options?: {
9
+ encrypt?: boolean;
10
+ },
11
+ ): Promise<any> {
6
12
  if (Array.isArray(value)) {
7
- return Promise.all(value.map((value) => serialize(scope, value)));
13
+ return Promise.all(value.map((value) => serialize(scope, value, options)));
8
14
  } else if (value instanceof Secret) {
9
15
  if (!scope.password) {
10
16
  throw new Error("Cannot serialize secret without password");
11
17
  }
12
18
  return {
13
- "@secret": await encryptWithKey(value.unencrypted, scope.password),
19
+ "@secret":
20
+ options?.encrypt !== false
21
+ ? await encryptWithKey(value.unencrypted, scope.password)
22
+ : value.unencrypted,
14
23
  };
15
24
  } else if (value instanceof Scope) {
16
25
  return undefined;
@@ -19,7 +28,7 @@ export async function serialize(scope: Scope, value: any): Promise<any> {
19
28
  await Promise.all(
20
29
  Object.entries(value).map(async ([key, value]) => [
21
30
  key,
22
- await serialize(scope, value),
31
+ await serialize(scope, value, options),
23
32
  ]),
24
33
  ),
25
34
  );
@@ -0,0 +1,68 @@
1
+ import { exec } from "child_process";
2
+ import { promisify } from "util";
3
+ import type { Context } from "../context";
4
+ import { Resource } from "../resource";
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ export type InstallDependencies = Resource<"project::InstallDependencies">;
9
+
10
+ export const InstallDependencies = Resource(
11
+ "project::InstallDependencies",
12
+ {
13
+ alwaysUpdate: true,
14
+ },
15
+ async function (
16
+ this: Context<InstallDependencies>,
17
+ id: string,
18
+ {
19
+ cwd,
20
+ dependencies,
21
+ devDependencies,
22
+ }: {
23
+ cwd: string;
24
+ dependencies?: Record<string, string>;
25
+ devDependencies?: Record<string, string>;
26
+ },
27
+ ) {
28
+ if (this.phase === "delete") {
29
+ return this.destroy();
30
+ }
31
+
32
+ await installDependencies(cwd, dependencies);
33
+ await installDependencies(cwd, devDependencies, "-D");
34
+ await execAsync("bun install", { cwd });
35
+
36
+ return this({});
37
+ },
38
+ );
39
+
40
+ function isInstallableVersion(version: string) {
41
+ // TODO: file:// ?
42
+ return !version.startsWith("workspace:");
43
+ }
44
+
45
+ export function fixedDependencies(dependencies?: Record<string, string>) {
46
+ if (!dependencies) return {};
47
+ return Object.fromEntries(
48
+ Object.entries(dependencies).filter(
49
+ ([, value]) => !isInstallableVersion(value),
50
+ ),
51
+ );
52
+ }
53
+
54
+ export async function installDependencies(
55
+ cwd: string,
56
+ dependencies: Record<string, string> | undefined,
57
+ ...args: string[]
58
+ ) {
59
+ if (!dependencies) return;
60
+ const deps = Object.entries(dependencies)
61
+ .filter(([, value]) => isInstallableVersion(value))
62
+ .map(([pkg, version]) => `${pkg}@${version}`)
63
+ .join(" ");
64
+
65
+ const cmd = `bun add ${args.join(" ")} ${deps}`;
66
+ console.log(cmd);
67
+ await execAsync(cmd, { cwd });
68
+ }