@alfredmouelle/create-stack 0.1.2 → 0.2.0
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/README.md +42 -14
- package/_stack/packages/analytics/capability.json +26 -0
- package/_stack/packages/analytics/package.json +26 -0
- package/_stack/packages/analytics/src/adapters/noop/index.ts +12 -0
- package/_stack/packages/analytics/src/adapters/plausible/config.ts +10 -0
- package/_stack/packages/analytics/src/adapters/plausible/index.ts +94 -0
- package/_stack/packages/analytics/src/adapters/posthog/config.ts +7 -0
- package/_stack/packages/analytics/src/adapters/posthog/index.ts +50 -0
- package/_stack/packages/analytics/src/core/port.ts +30 -0
- package/_stack/packages/analytics/src/index.ts +17 -0
- package/_stack/packages/cache/capability.json +21 -0
- package/_stack/packages/cache/package.json +25 -0
- package/_stack/packages/cache/src/adapters/memory/index.ts +51 -0
- package/_stack/packages/cache/src/adapters/redis/config.ts +8 -0
- package/_stack/packages/cache/src/adapters/redis/index.ts +73 -0
- package/_stack/packages/cache/src/core/port.ts +29 -0
- package/_stack/packages/cache/src/core/wrap.ts +20 -0
- package/_stack/packages/cache/src/index.ts +12 -0
- package/_stack/packages/error-tracking/capability.json +21 -0
- package/_stack/packages/error-tracking/package.json +25 -0
- package/_stack/packages/error-tracking/src/adapters/console/index.ts +43 -0
- package/_stack/packages/error-tracking/src/adapters/sentry/config.ts +8 -0
- package/_stack/packages/error-tracking/src/adapters/sentry/index.ts +72 -0
- package/_stack/packages/error-tracking/src/core/port.ts +39 -0
- package/_stack/packages/error-tracking/src/index.ts +14 -0
- package/_stack/packages/http/package.json +20 -0
- package/_stack/packages/http/src/api.ts +373 -0
- package/_stack/packages/http/src/index.ts +14 -0
- package/_stack/packages/http/src/responses.ts +25 -0
- package/_stack/packages/http/src/types.ts +9 -0
- package/_stack/packages/jobs/capability.json +26 -0
- package/_stack/packages/jobs/package.json +27 -0
- package/_stack/packages/jobs/src/adapters/inngest/config.ts +8 -0
- package/_stack/packages/jobs/src/adapters/inngest/index.ts +93 -0
- package/_stack/packages/jobs/src/adapters/memory/index.ts +31 -0
- package/_stack/packages/jobs/src/adapters/trigger/config.ts +8 -0
- package/_stack/packages/jobs/src/adapters/trigger/index.ts +85 -0
- package/_stack/packages/jobs/src/core/port.ts +37 -0
- package/_stack/packages/jobs/src/index.ts +23 -0
- package/_stack/packages/logger/capability.json +21 -0
- package/_stack/packages/logger/package.json +25 -0
- package/_stack/packages/logger/src/adapters/console/config.ts +7 -0
- package/_stack/packages/logger/src/adapters/console/index.ts +69 -0
- package/_stack/packages/logger/src/adapters/pino/index.ts +54 -0
- package/_stack/packages/logger/src/core/port.ts +21 -0
- package/_stack/packages/logger/src/index.ts +12 -0
- package/_stack/packages/storage/capability.json +32 -0
- package/_stack/packages/storage/package.json +27 -0
- package/_stack/packages/storage/src/adapters/gcs/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/gcs/index.ts +111 -0
- package/_stack/packages/storage/src/adapters/local/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/local/index.ts +78 -0
- package/_stack/packages/storage/src/adapters/r2/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/r2/index.ts +39 -0
- package/_stack/packages/storage/src/adapters/s3/config.ts +11 -0
- package/_stack/packages/storage/src/adapters/s3/index.ts +143 -0
- package/_stack/packages/storage/src/core/port.ts +41 -0
- package/_stack/packages/storage/src/index.ts +21 -0
- package/index.mjs +69 -18
- package/lib/build.mjs +23 -5
- package/lib/capabilities.mjs +375 -0
- package/lib/env.mjs +21 -0
- package/lib/scaffold.mjs +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Storage } from '@google-cloud/storage'
|
|
2
|
+
import * as v from 'valibot'
|
|
3
|
+
import {
|
|
4
|
+
type PutOptions,
|
|
5
|
+
type SignedUrlOptions,
|
|
6
|
+
StorageError,
|
|
7
|
+
type StoragePort,
|
|
8
|
+
} from '../../core/port.js'
|
|
9
|
+
import { GcsConfigSchema } from './config.js'
|
|
10
|
+
|
|
11
|
+
/** Structural view of a GCS file handle (eases testing). */
|
|
12
|
+
export interface GcsFileLike {
|
|
13
|
+
save(data: Buffer | string, options?: { contentType?: string }): Promise<void>
|
|
14
|
+
download(): Promise<[Buffer]>
|
|
15
|
+
delete(): Promise<unknown>
|
|
16
|
+
exists(): Promise<[boolean]>
|
|
17
|
+
getSignedUrl(options: {
|
|
18
|
+
version: 'v4'
|
|
19
|
+
action: 'read' | 'write'
|
|
20
|
+
expires: number
|
|
21
|
+
contentType?: string
|
|
22
|
+
}): Promise<[string]>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Structural view of a GCS bucket handle. */
|
|
26
|
+
export interface GcsBucketLike {
|
|
27
|
+
file(key: string): GcsFileLike
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Structural view of the GCS Storage client. */
|
|
31
|
+
export interface GcsStorageLike {
|
|
32
|
+
bucket(name: string): GcsBucketLike
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GcsAdapterOptions {
|
|
36
|
+
bucket: string
|
|
37
|
+
projectId?: string
|
|
38
|
+
/** Inject custom/mock client. Defaults to real `Storage`. */
|
|
39
|
+
client?: GcsStorageLike
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_EXPIRES_IN = 900
|
|
43
|
+
|
|
44
|
+
function isNotFound(error: unknown): boolean {
|
|
45
|
+
if (typeof error !== 'object' || error === null) return false
|
|
46
|
+
return (error as { code?: number }).code === 404
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function gcsAdapter(options: GcsAdapterOptions): StoragePort {
|
|
50
|
+
// Validate early: missing config fails at construction, not use.
|
|
51
|
+
const config = v.parse(GcsConfigSchema, {
|
|
52
|
+
bucket: options.bucket,
|
|
53
|
+
projectId: options.projectId,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const client: GcsStorageLike =
|
|
57
|
+
options.client ?? (new Storage({ projectId: config.projectId }) as unknown as GcsStorageLike)
|
|
58
|
+
|
|
59
|
+
const bucket = client.bucket(config.bucket)
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name: 'gcs',
|
|
63
|
+
async put(key: string, data: Uint8Array | string, putOptions?: PutOptions) {
|
|
64
|
+
const body = typeof data === 'string' ? data : Buffer.from(data)
|
|
65
|
+
try {
|
|
66
|
+
await bucket.file(key).save(body, { contentType: putOptions?.contentType })
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
throw new StorageError('GCS put failed', { adapter: 'gcs', cause })
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
async get(key: string) {
|
|
72
|
+
try {
|
|
73
|
+
const [contents] = await bucket.file(key).download()
|
|
74
|
+
return new Uint8Array(contents)
|
|
75
|
+
} catch (cause) {
|
|
76
|
+
if (isNotFound(cause)) return null
|
|
77
|
+
throw new StorageError('GCS get failed', { adapter: 'gcs', cause })
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
async delete(key: string) {
|
|
81
|
+
try {
|
|
82
|
+
await bucket.file(key).delete()
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
if (isNotFound(cause)) return
|
|
85
|
+
throw new StorageError('GCS delete failed', { adapter: 'gcs', cause })
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async exists(key: string) {
|
|
89
|
+
try {
|
|
90
|
+
const [found] = await bucket.file(key).exists()
|
|
91
|
+
return found
|
|
92
|
+
} catch (cause) {
|
|
93
|
+
throw new StorageError('GCS exists failed', { adapter: 'gcs', cause })
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
async getSignedUrl(key: string, urlOptions: SignedUrlOptions) {
|
|
97
|
+
const expiresInSeconds = urlOptions.expiresInSeconds ?? DEFAULT_EXPIRES_IN
|
|
98
|
+
try {
|
|
99
|
+
const [url] = await bucket.file(key).getSignedUrl({
|
|
100
|
+
version: 'v4',
|
|
101
|
+
action: urlOptions.operation === 'put' ? 'write' : 'read',
|
|
102
|
+
expires: Date.now() + expiresInSeconds * 1000,
|
|
103
|
+
contentType: urlOptions.contentType,
|
|
104
|
+
})
|
|
105
|
+
return url
|
|
106
|
+
} catch (cause) {
|
|
107
|
+
throw new StorageError('GCS getSignedUrl failed', { adapter: 'gcs', cause })
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
|
|
3
|
+
export const LocalConfigSchema = v.object({
|
|
4
|
+
baseDir: v.pipe(v.string(), v.minLength(1, 'STORAGE_LOCAL_DIR is required')),
|
|
5
|
+
publicBaseUrl: v.optional(v.string()),
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
export type LocalConfig = v.InferOutput<typeof LocalConfigSchema>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import * as v from 'valibot'
|
|
4
|
+
import {
|
|
5
|
+
type PutOptions,
|
|
6
|
+
type SignedUrlOptions,
|
|
7
|
+
StorageError,
|
|
8
|
+
type StoragePort,
|
|
9
|
+
} from '../../core/port.js'
|
|
10
|
+
import { LocalConfigSchema } from './config.js'
|
|
11
|
+
|
|
12
|
+
export interface LocalAdapterOptions {
|
|
13
|
+
/** Root directory for stored objects. */
|
|
14
|
+
baseDir: string
|
|
15
|
+
/**
|
|
16
|
+
* Base URL prefixed to keys by {@link StoragePort.getSignedUrl}. NOT signed —
|
|
17
|
+
* returns `${publicBaseUrl}/${key}`. Dev/tests only.
|
|
18
|
+
*/
|
|
19
|
+
publicBaseUrl?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isNotFound(error: unknown): boolean {
|
|
23
|
+
if (typeof error !== 'object' || error === null) return false
|
|
24
|
+
return (error as { code?: string }).code === 'ENOENT'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function localAdapter(options: LocalAdapterOptions): StoragePort {
|
|
28
|
+
const config = v.parse(LocalConfigSchema, {
|
|
29
|
+
baseDir: options.baseDir,
|
|
30
|
+
publicBaseUrl: options.publicBaseUrl,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const resolve = (key: string): string => join(config.baseDir, key)
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
name: 'local',
|
|
37
|
+
async put(key: string, data: Uint8Array | string, _options?: PutOptions) {
|
|
38
|
+
const path = resolve(key)
|
|
39
|
+
try {
|
|
40
|
+
await mkdir(dirname(path), { recursive: true })
|
|
41
|
+
await writeFile(path, typeof data === 'string' ? data : Buffer.from(data))
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
throw new StorageError('Local put failed', { adapter: 'local', cause })
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
async get(key: string) {
|
|
47
|
+
try {
|
|
48
|
+
const buffer = await readFile(resolve(key))
|
|
49
|
+
return new Uint8Array(buffer)
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
if (isNotFound(cause)) return null
|
|
52
|
+
throw new StorageError('Local get failed', { adapter: 'local', cause })
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
async delete(key: string) {
|
|
56
|
+
try {
|
|
57
|
+
await unlink(resolve(key))
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
if (isNotFound(cause)) return
|
|
60
|
+
throw new StorageError('Local delete failed', { adapter: 'local', cause })
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
async exists(key: string) {
|
|
64
|
+
try {
|
|
65
|
+
await stat(resolve(key))
|
|
66
|
+
return true
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
if (isNotFound(cause)) return false
|
|
69
|
+
throw new StorageError('Local exists failed', { adapter: 'local', cause })
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
async getSignedUrl(key: string, _options: SignedUrlOptions) {
|
|
73
|
+
// No real signing; dev/test only.
|
|
74
|
+
const base = config.publicBaseUrl ?? ''
|
|
75
|
+
return `${base}/${key}`
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
|
|
3
|
+
export const R2ConfigSchema = v.object({
|
|
4
|
+
bucket: v.pipe(v.string(), v.minLength(1, 'R2_BUCKET is required')),
|
|
5
|
+
accountId: v.pipe(v.string(), v.minLength(1, 'R2_ACCOUNT_ID is required')),
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
export type R2Config = v.InferOutput<typeof R2ConfigSchema>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
import type { StoragePort } from '../../core/port.js'
|
|
3
|
+
import { type S3ClientLike, type S3Presigner, s3Adapter } from '../s3/index.js'
|
|
4
|
+
import { R2ConfigSchema } from './config.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* R2 is S3-compatible: {@link s3Adapter} with R2's endpoint
|
|
8
|
+
* (`https://<accountId>.r2.cloudflarestorage.com`) and `auto` region. All behavior inherited.
|
|
9
|
+
*/
|
|
10
|
+
export interface R2AdapterOptions {
|
|
11
|
+
bucket: string
|
|
12
|
+
/** Cloudflare account id (forms the R2 endpoint). */
|
|
13
|
+
accountId: string
|
|
14
|
+
accessKeyId?: string
|
|
15
|
+
secretAccessKey?: string
|
|
16
|
+
/** Inject custom/mock client. Defaults to real `S3Client`. */
|
|
17
|
+
client?: S3ClientLike
|
|
18
|
+
/** Inject custom/mock presigner. */
|
|
19
|
+
presign?: S3Presigner
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function r2Adapter(options: R2AdapterOptions): StoragePort {
|
|
23
|
+
const config = v.parse(R2ConfigSchema, {
|
|
24
|
+
bucket: options.bucket,
|
|
25
|
+
accountId: options.accountId,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const s3 = s3Adapter({
|
|
29
|
+
bucket: config.bucket,
|
|
30
|
+
region: 'auto',
|
|
31
|
+
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
|
|
32
|
+
accessKeyId: options.accessKeyId,
|
|
33
|
+
secretAccessKey: options.secretAccessKey,
|
|
34
|
+
client: options.client,
|
|
35
|
+
presign: options.presign,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return { ...s3, name: 'r2' }
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
|
|
3
|
+
export const S3ConfigSchema = v.object({
|
|
4
|
+
bucket: v.pipe(v.string(), v.minLength(1, 'S3_BUCKET is required')),
|
|
5
|
+
region: v.pipe(v.string(), v.minLength(1, 'S3_REGION is required')),
|
|
6
|
+
accessKeyId: v.optional(v.string()),
|
|
7
|
+
secretAccessKey: v.optional(v.string()),
|
|
8
|
+
endpoint: v.optional(v.string()),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export type S3Config = v.InferOutput<typeof S3ConfigSchema>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DeleteObjectCommand,
|
|
3
|
+
GetObjectCommand,
|
|
4
|
+
HeadObjectCommand,
|
|
5
|
+
PutObjectCommand,
|
|
6
|
+
S3Client,
|
|
7
|
+
} from '@aws-sdk/client-s3'
|
|
8
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
9
|
+
import * as v from 'valibot'
|
|
10
|
+
import {
|
|
11
|
+
type PutOptions,
|
|
12
|
+
type SignedUrlOptions,
|
|
13
|
+
StorageError,
|
|
14
|
+
type StoragePort,
|
|
15
|
+
} from '../../core/port.js'
|
|
16
|
+
import { S3ConfigSchema } from './config.js'
|
|
17
|
+
|
|
18
|
+
/** Structural view of the S3 client (eases testing). */
|
|
19
|
+
export interface S3ClientLike {
|
|
20
|
+
send(command: unknown): Promise<unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Presigner shape, injectable so tests never sign for real. */
|
|
24
|
+
export type S3Presigner = (
|
|
25
|
+
client: S3ClientLike,
|
|
26
|
+
command: unknown,
|
|
27
|
+
options: { expiresIn: number },
|
|
28
|
+
) => Promise<string>
|
|
29
|
+
|
|
30
|
+
export interface S3AdapterOptions {
|
|
31
|
+
bucket: string
|
|
32
|
+
region: string
|
|
33
|
+
accessKeyId?: string
|
|
34
|
+
secretAccessKey?: string
|
|
35
|
+
endpoint?: string
|
|
36
|
+
/** Inject custom/mock client. Defaults to real `S3Client`. */
|
|
37
|
+
client?: S3ClientLike
|
|
38
|
+
/** Inject custom/mock presigner. Defaults to `@aws-sdk/s3-request-presigner`. */
|
|
39
|
+
presign?: S3Presigner
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_EXPIRES_IN = 900
|
|
43
|
+
|
|
44
|
+
interface S3GetBody {
|
|
45
|
+
transformToByteArray(): Promise<Uint8Array>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface S3GetResult {
|
|
49
|
+
// biome-ignore lint/style/useNamingConvention: mirrors the S3 SDK response shape (GetObjectCommandOutput.Body)
|
|
50
|
+
Body?: S3GetBody
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isNotFound(error: unknown): boolean {
|
|
54
|
+
if (typeof error !== 'object' || error === null) return false
|
|
55
|
+
const e = error as { name?: string; $metadata?: { httpStatusCode?: number } }
|
|
56
|
+
return e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function s3Adapter(options: S3AdapterOptions): StoragePort {
|
|
60
|
+
// Validate early: missing config fails at construction, not use.
|
|
61
|
+
const config = v.parse(S3ConfigSchema, {
|
|
62
|
+
bucket: options.bucket,
|
|
63
|
+
region: options.region,
|
|
64
|
+
accessKeyId: options.accessKeyId,
|
|
65
|
+
secretAccessKey: options.secretAccessKey,
|
|
66
|
+
endpoint: options.endpoint,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const client: S3ClientLike =
|
|
70
|
+
options.client ??
|
|
71
|
+
(new S3Client({
|
|
72
|
+
region: config.region,
|
|
73
|
+
endpoint: config.endpoint,
|
|
74
|
+
credentials:
|
|
75
|
+
config.accessKeyId && config.secretAccessKey
|
|
76
|
+
? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
|
|
77
|
+
: undefined,
|
|
78
|
+
}) as unknown as S3ClientLike)
|
|
79
|
+
|
|
80
|
+
const presign: S3Presigner = options.presign ?? (getSignedUrl as unknown as S3Presigner)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
name: 's3',
|
|
84
|
+
async put(key: string, data: Uint8Array | string, putOptions?: PutOptions) {
|
|
85
|
+
try {
|
|
86
|
+
await client.send(
|
|
87
|
+
new PutObjectCommand({
|
|
88
|
+
Bucket: config.bucket,
|
|
89
|
+
Key: key,
|
|
90
|
+
Body: data,
|
|
91
|
+
ContentType: putOptions?.contentType,
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
throw new StorageError('S3 put failed', { adapter: 's3', cause })
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
async get(key: string) {
|
|
99
|
+
try {
|
|
100
|
+
const result = (await client.send(
|
|
101
|
+
new GetObjectCommand({ Bucket: config.bucket, Key: key }),
|
|
102
|
+
)) as S3GetResult
|
|
103
|
+
if (!result.Body) return null
|
|
104
|
+
return await result.Body.transformToByteArray()
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
if (isNotFound(cause)) return null
|
|
107
|
+
throw new StorageError('S3 get failed', { adapter: 's3', cause })
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
async delete(key: string) {
|
|
111
|
+
try {
|
|
112
|
+
await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }))
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
throw new StorageError('S3 delete failed', { adapter: 's3', cause })
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
async exists(key: string) {
|
|
118
|
+
try {
|
|
119
|
+
await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: key }))
|
|
120
|
+
return true
|
|
121
|
+
} catch (cause) {
|
|
122
|
+
if (isNotFound(cause)) return false
|
|
123
|
+
throw new StorageError('S3 exists failed', { adapter: 's3', cause })
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
async getSignedUrl(key: string, urlOptions: SignedUrlOptions) {
|
|
127
|
+
const expiresIn = urlOptions.expiresInSeconds ?? DEFAULT_EXPIRES_IN
|
|
128
|
+
const command =
|
|
129
|
+
urlOptions.operation === 'put'
|
|
130
|
+
? new PutObjectCommand({
|
|
131
|
+
Bucket: config.bucket,
|
|
132
|
+
Key: key,
|
|
133
|
+
ContentType: urlOptions.contentType,
|
|
134
|
+
})
|
|
135
|
+
: new GetObjectCommand({ Bucket: config.bucket, Key: key })
|
|
136
|
+
try {
|
|
137
|
+
return await presign(client, command, { expiresIn })
|
|
138
|
+
} catch (cause) {
|
|
139
|
+
throw new StorageError('S3 getSignedUrl failed', { adapter: 's3', cause })
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Write options. */
|
|
2
|
+
export interface PutOptions {
|
|
3
|
+
/** MIME type stored with the object (e.g. `image/png`). */
|
|
4
|
+
contentType?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Signed-URL options. */
|
|
8
|
+
export interface SignedUrlOptions {
|
|
9
|
+
/** Download (`get`) or upload (`put`). */
|
|
10
|
+
operation: 'get' | 'put'
|
|
11
|
+
/** Validity window. Adapters apply a default. */
|
|
12
|
+
expiresInSeconds?: number
|
|
13
|
+
/** Constrain content type for `put` URLs. */
|
|
14
|
+
contentType?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The port the app depends on. Swap provider = swap adapter; this never changes. */
|
|
18
|
+
export interface StoragePort {
|
|
19
|
+
readonly name: string
|
|
20
|
+
/** Write `data` at `key`, creating or overwriting. */
|
|
21
|
+
put(key: string, data: Uint8Array | string, options?: PutOptions): Promise<void>
|
|
22
|
+
/** Read bytes at `key`, or `null` if absent. */
|
|
23
|
+
get(key: string): Promise<Uint8Array | null>
|
|
24
|
+
/** Remove `key`. No-op if missing. */
|
|
25
|
+
delete(key: string): Promise<void>
|
|
26
|
+
/** Whether `key` exists. */
|
|
27
|
+
exists(key: string): Promise<boolean>
|
|
28
|
+
/** Mint a time-limited URL for `key`. */
|
|
29
|
+
getSignedUrl(key: string, options: SignedUrlOptions): Promise<string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Normalized adapter error so callers never catch provider types. */
|
|
33
|
+
export class StorageError extends Error {
|
|
34
|
+
readonly adapter: string
|
|
35
|
+
|
|
36
|
+
constructor(message: string, options: { adapter: string; cause?: unknown }) {
|
|
37
|
+
super(message, { cause: options.cause })
|
|
38
|
+
this.name = 'StorageError'
|
|
39
|
+
this.adapter = options.adapter
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { type GcsConfig, GcsConfigSchema } from './adapters/gcs/config.js'
|
|
2
|
+
export {
|
|
3
|
+
type GcsAdapterOptions,
|
|
4
|
+
type GcsBucketLike,
|
|
5
|
+
type GcsFileLike,
|
|
6
|
+
type GcsStorageLike,
|
|
7
|
+
gcsAdapter,
|
|
8
|
+
} from './adapters/gcs/index.js'
|
|
9
|
+
export { type LocalConfig, LocalConfigSchema } from './adapters/local/config.js'
|
|
10
|
+
export { type LocalAdapterOptions, localAdapter } from './adapters/local/index.js'
|
|
11
|
+
export { type R2Config, R2ConfigSchema } from './adapters/r2/config.js'
|
|
12
|
+
export { type R2AdapterOptions, r2Adapter } from './adapters/r2/index.js'
|
|
13
|
+
export { type S3Config, S3ConfigSchema } from './adapters/s3/config.js'
|
|
14
|
+
export {
|
|
15
|
+
type S3AdapterOptions,
|
|
16
|
+
type S3ClientLike,
|
|
17
|
+
type S3Presigner,
|
|
18
|
+
s3Adapter,
|
|
19
|
+
} from './adapters/s3/index.js'
|
|
20
|
+
export type { PutOptions, SignedUrlOptions, StoragePort } from './core/port.js'
|
|
21
|
+
export { StorageError } from './core/port.js'
|
package/index.mjs
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
import { resolve } from 'node:path'
|
|
7
7
|
import * as p from '@clack/prompts'
|
|
8
8
|
import { buildProject } from './lib/build.mjs'
|
|
9
|
+
import {
|
|
10
|
+
adapterChoices,
|
|
11
|
+
CAPABILITIES,
|
|
12
|
+
capabilityChoices,
|
|
13
|
+
resolveAdapter,
|
|
14
|
+
} from './lib/capabilities.mjs'
|
|
9
15
|
import { isDirEmpty, run } from './lib/util.mjs'
|
|
10
16
|
|
|
11
17
|
const ALL_FOUNDATIONS = ['drizzle', 'trpc', 'better-auth', 'data-table']
|
|
@@ -56,14 +62,24 @@ function normalize(picked, mailer) {
|
|
|
56
62
|
return { kept, mailerProvider }
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
/** Read --<capability> flags into { capability: adapter } (default adapter if bare). */
|
|
66
|
+
function collectCapabilityFlags(flags) {
|
|
67
|
+
const out = {}
|
|
68
|
+
for (const cap of CAPABILITIES) {
|
|
69
|
+
if (cap in flags) out[cap] = resolveAdapter(cap, flags[cap])
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
function collectFromFlags(args) {
|
|
60
75
|
const argDir = args._[0]
|
|
61
76
|
if (!argDir) throw new Error('Project name is required (positional) in non-interactive mode')
|
|
62
77
|
const framework = args.flags.framework === 'next' ? 'next' : 'tanstack'
|
|
63
78
|
const picked = args.flags.foundations ? csv(args.flags.foundations) : [...ALL_FOUNDATIONS]
|
|
64
79
|
const { kept, mailerProvider } = normalize(picked, args.flags.mailer)
|
|
80
|
+
const capabilities = collectCapabilityFlags(args.flags)
|
|
65
81
|
const doInstall = !args.flags['no-install']
|
|
66
|
-
return { argDir, projectName: argDir, framework, kept, mailerProvider, doInstall }
|
|
82
|
+
return { argDir, projectName: argDir, framework, kept, mailerProvider, capabilities, doInstall }
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
async function collectFromPrompts(argDir) {
|
|
@@ -118,12 +134,56 @@ async function collectFromPrompts(argDir) {
|
|
|
118
134
|
}),
|
|
119
135
|
)
|
|
120
136
|
|
|
137
|
+
const capPicked = cancelled(
|
|
138
|
+
await p.multiselect({
|
|
139
|
+
message: 'Capabilities (space to toggle, swappable behind a port)',
|
|
140
|
+
required: false,
|
|
141
|
+
initialValues: [],
|
|
142
|
+
options: capabilityChoices(),
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const capabilities = {}
|
|
147
|
+
for (const cap of capPicked) {
|
|
148
|
+
const { defaultAdapter, options } = adapterChoices(cap)
|
|
149
|
+
capabilities[cap] = cancelled(
|
|
150
|
+
await p.select({
|
|
151
|
+
message: `${cap} adapter`,
|
|
152
|
+
options,
|
|
153
|
+
initialValue: defaultAdapter,
|
|
154
|
+
}),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
121
158
|
const doInstall = cancelled(
|
|
122
159
|
await p.confirm({ message: 'Install dependencies and verify now?', initialValue: true }),
|
|
123
160
|
)
|
|
124
161
|
|
|
125
162
|
const { kept, mailerProvider } = normalize(picked, mailer)
|
|
126
|
-
return { argDir, projectName, framework, kept, mailerProvider, doInstall }
|
|
163
|
+
return { argDir, projectName, framework, kept, mailerProvider, capabilities, doInstall }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pnpmRun = (script, projectDir, opts = {}) =>
|
|
167
|
+
run('pnpm', ['--config.verify-deps-before-run=false', 'run', script], {
|
|
168
|
+
cwd: projectDir,
|
|
169
|
+
...opts,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
/** Install deps, normalize vendored imports, then report typecheck + biome status. */
|
|
173
|
+
function installAndVerify(projectDir, capabilities) {
|
|
174
|
+
p.log.step('pnpm install')
|
|
175
|
+
run('pnpm', ['install'], { cwd: projectDir })
|
|
176
|
+
// vendored capabilities rewrite cross-package imports (~/lib/http); let biome
|
|
177
|
+
// re-sort/normalize them so the initial commit is lint-clean.
|
|
178
|
+
if (Object.keys(capabilities ?? {}).length) {
|
|
179
|
+
pnpmRun('check:write', projectDir, { stdio: 'ignore' })
|
|
180
|
+
}
|
|
181
|
+
p.log.step('Verifying (typecheck + biome)')
|
|
182
|
+
const tc = pnpmRun('typecheck', projectDir)
|
|
183
|
+
const lint = pnpmRun('check', projectDir)
|
|
184
|
+
p.log[tc && lint ? 'success' : 'warn'](
|
|
185
|
+
tc && lint ? 'typecheck + biome clean' : 'verify reported issues (see output above)',
|
|
186
|
+
)
|
|
127
187
|
}
|
|
128
188
|
|
|
129
189
|
function execute(a) {
|
|
@@ -138,20 +198,7 @@ function execute(a) {
|
|
|
138
198
|
buildProject({ ...a, projectDir })
|
|
139
199
|
s.stop('Project scaffolded')
|
|
140
200
|
|
|
141
|
-
if (a.doInstall)
|
|
142
|
-
p.log.step('pnpm install')
|
|
143
|
-
run('pnpm', ['install'], { cwd: projectDir })
|
|
144
|
-
p.log.step('Verifying (typecheck + biome)')
|
|
145
|
-
const tc = run('pnpm', ['--config.verify-deps-before-run=false', 'run', 'typecheck'], {
|
|
146
|
-
cwd: projectDir,
|
|
147
|
-
})
|
|
148
|
-
const lint = run('pnpm', ['--config.verify-deps-before-run=false', 'run', 'check'], {
|
|
149
|
-
cwd: projectDir,
|
|
150
|
-
})
|
|
151
|
-
p.log[tc && lint ? 'success' : 'warn'](
|
|
152
|
-
tc && lint ? 'typecheck + biome clean' : 'verify reported issues (see output above)',
|
|
153
|
-
)
|
|
154
|
-
}
|
|
201
|
+
if (a.doInstall) installAndVerify(projectDir, a.capabilities)
|
|
155
202
|
|
|
156
203
|
// fresh repo + initial commit (also satisfies Biome vcs.useIgnoreFile).
|
|
157
204
|
// commit is best-effort: skipped if git identity unset, staged tree left in place.
|
|
@@ -170,12 +217,14 @@ function execute(a) {
|
|
|
170
217
|
}
|
|
171
218
|
|
|
172
219
|
const keptMailer = a.mailerProvider !== 'none'
|
|
220
|
+
const capEntries = Object.entries(a.capabilities ?? {})
|
|
173
221
|
const lines = [
|
|
174
222
|
`Framework: ${a.framework === 'next' ? 'Next.js' : 'TanStack Start'}`,
|
|
175
223
|
`Foundations: ${[...a.kept].sort().join(', ') || '(none)'}`,
|
|
176
224
|
`Mailer: ${keptMailer ? a.mailerProvider : '(none)'}`,
|
|
225
|
+
`Capabilities: ${capEntries.map(([c, ad]) => `${c} (${ad})`).join(', ') || '(none)'}`,
|
|
177
226
|
'',
|
|
178
|
-
'Add more tools
|
|
227
|
+
'Add more tools later with the add-capability skill.',
|
|
179
228
|
'',
|
|
180
229
|
'Next:',
|
|
181
230
|
` cd ${a.argDir ?? a.projectName}`,
|
|
@@ -192,7 +241,9 @@ async function main() {
|
|
|
192
241
|
const nonInteractive =
|
|
193
242
|
args.flags.yes ||
|
|
194
243
|
args.flags.y ||
|
|
195
|
-
['framework', 'foundations', 'mailer', 'no-install'].some(
|
|
244
|
+
['framework', 'foundations', 'mailer', 'no-install', ...CAPABILITIES].some(
|
|
245
|
+
(k) => k in args.flags,
|
|
246
|
+
)
|
|
196
247
|
|
|
197
248
|
const answers = nonInteractive ? collectFromFlags(args) : await collectFromPrompts(args._[0])
|
|
198
249
|
|
package/lib/build.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Pure build phase (no prompts/install): fork → strip → mailer → env → identity.
|
|
2
2
|
// Shared by index.mjs (post-wizard) and the test harness.
|
|
3
3
|
|
|
4
|
+
import { vendorCapability } from './capabilities.mjs'
|
|
4
5
|
import { writeEnv } from './env.mjs'
|
|
5
6
|
import { stampIdentity } from './identity.mjs'
|
|
6
7
|
import { swapMailer } from './mailer.mjs'
|
|
@@ -15,9 +16,17 @@ import { join, pkgAddDeps, pkgRemoveDeps, pkgRemoveScripts, readJSON, writeJSON
|
|
|
15
16
|
* @param {'next'|'tanstack'} o.framework
|
|
16
17
|
* @param {Set<string>} o.kept foundations to keep (deps pre-resolved)
|
|
17
18
|
* @param {'resend'|'brevo'|'ses'|'none'} o.mailerProvider
|
|
18
|
-
* @
|
|
19
|
+
* @param {Record<string,string>} [o.capabilities] capability → adapter (e.g. { storage: 's3' })
|
|
20
|
+
* @returns {{ kept: string[], keptMailer: boolean, mailerProvider: string, capabilities: Record<string,string>, envKeys: string[] }}
|
|
19
21
|
*/
|
|
20
|
-
export function buildProject({
|
|
22
|
+
export function buildProject({
|
|
23
|
+
projectDir,
|
|
24
|
+
projectName,
|
|
25
|
+
framework,
|
|
26
|
+
kept,
|
|
27
|
+
mailerProvider,
|
|
28
|
+
capabilities = {},
|
|
29
|
+
}) {
|
|
21
30
|
const authKept = kept.has('better-auth')
|
|
22
31
|
const keptMailer = mailerProvider !== 'none'
|
|
23
32
|
|
|
@@ -29,12 +38,21 @@ export function buildProject({ projectDir, projectName, framework, kept, mailerP
|
|
|
29
38
|
? swapMailer(projectDir, mailerProvider)
|
|
30
39
|
: { addDeps: {}, removeDeps: [], envKeys: [] }
|
|
31
40
|
|
|
41
|
+
// vendor each selected capability (core + adapter + composition root) into the fork.
|
|
42
|
+
const capAddDeps = {}
|
|
43
|
+
const capEnvKeys = []
|
|
44
|
+
for (const [cap, adapter] of Object.entries(capabilities)) {
|
|
45
|
+
const r = vendorCapability({ projectDir, framework, projectName, cap, adapter })
|
|
46
|
+
Object.assign(capAddDeps, r.addDeps)
|
|
47
|
+
capEnvKeys.push(...r.envKeys)
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
const pkgPath = join(projectDir, 'package.json')
|
|
33
51
|
const pkg = readJSON(pkgPath)
|
|
34
52
|
pkg.description = `${projectName} — scaffolded from the personal reference stack.`
|
|
35
53
|
pkgRemoveDeps(pkg, [...strip.removeDeps, ...mailer.removeDeps])
|
|
36
54
|
pkgRemoveScripts(pkg, strip.removeScripts)
|
|
37
|
-
pkgAddDeps(pkg, mailer.addDeps)
|
|
55
|
+
pkgAddDeps(pkg, { ...mailer.addDeps, ...capAddDeps })
|
|
38
56
|
writeJSON(pkgPath, pkg)
|
|
39
57
|
|
|
40
58
|
const envKeys = []
|
|
@@ -47,10 +65,10 @@ export function buildProject({ projectDir, projectName, framework, kept, mailerP
|
|
|
47
65
|
'BETTER_AUTH_GOOGLE_CLIENT_SECRET',
|
|
48
66
|
)
|
|
49
67
|
}
|
|
50
|
-
envKeys.push(...mailer.envKeys)
|
|
68
|
+
envKeys.push(...mailer.envKeys, ...capEnvKeys)
|
|
51
69
|
writeEnv(projectDir, envKeys)
|
|
52
70
|
|
|
53
71
|
stampIdentity(projectDir, projectName, framework)
|
|
54
72
|
|
|
55
|
-
return { kept: [...kept], keptMailer, mailerProvider, envKeys }
|
|
73
|
+
return { kept: [...kept], keptMailer, mailerProvider, capabilities, envKeys }
|
|
56
74
|
}
|