@downdraft/cli 0.0.0 → 0.1.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 +11 -0
- package/package.json +36 -3
- package/scripts/package-mobile.mjs +779 -0
- package/scripts/package-native.mjs +616 -0
- package/src/args.ts +333 -0
- package/src/assets.ts +523 -0
- package/src/blob-store-s3.ts +145 -0
- package/src/build-games.ts +45 -0
- package/src/build.ts +40 -0
- package/src/bun-env.d.ts +6 -0
- package/src/debug.ts +56 -0
- package/src/dev.ts +231 -0
- package/src/dist.ts +36 -0
- package/src/export.ts +38 -0
- package/src/index.ts +131 -0
- package/src/list-games.ts +37 -0
- package/src/mcp.ts +320 -0
- package/src/new.ts +63 -0
- package/src/paths.ts +101 -0
- package/src/plugin-command.ts +214 -0
- package/src/process-utils.ts +179 -0
- package/src/release.ts +374 -0
- package/src/scaffold-plugin.ts +280 -0
- package/src/scaffold.ts +207 -0
- package/src/test.ts +187 -0
- package/src/usage.ts +255 -0
- package/templates/_shared/ai-companion/devin-config.eta +11 -0
- package/templates/_shared/ai-companion/engine-prompt.eta +155 -0
- package/templates/_shared/ai-companion/logging-rule.eta +3 -0
- package/templates/_shared/ai-companion/loops-rule.eta +3 -0
- package/templates/_shared/partials/config-json-fields.eta +3 -0
- package/templates/_shared/partials/dev-deps.eta +8 -0
- package/templates/_shared/partials/package-json-fields.eta +12 -0
- package/templates/_shared/partials/tsconfig.eta +16 -0
- package/templates/_shared/partials/vscode-extensions.eta +11 -0
- package/templates/_shared/partials/vscode-settings.eta +29 -0
- package/templates/_shared/partials/vscode-tasks.eta +93 -0
- package/templates/full/.gitignore.eta +5 -0
- package/templates/full/.vscode/extensions.json.eta +1 -0
- package/templates/full/.vscode/settings.json.eta +1 -0
- package/templates/full/.vscode/tasks.json.eta +1 -0
- package/templates/full/README.md.eta +45 -0
- package/templates/full/assets/shaders/.eta +0 -0
- package/templates/full/downdraft.config.json.eta +10 -0
- package/templates/full/package.json.eta +9 -0
- package/templates/full/src/entities/.eta +0 -0
- package/templates/full/src/game-module.ts.eta +30 -0
- package/templates/full/src/native-entry.ts.eta +14 -0
- package/templates/full/src/sim-host.ts.eta +19 -0
- package/templates/full/src/sim-worker.ts.eta +99 -0
- package/templates/full/src/systems/.eta +0 -0
- package/templates/full/tsconfig.json.eta +1 -0
- package/templates/gamemodule/README.md.eta +32 -0
- package/templates/gamemodule/downdraft.config.json.eta +3 -0
- package/templates/gamemodule/package.json.eta +9 -0
- package/templates/gamemodule/src/game-module.ts.eta +63 -0
- package/templates/gamemodule/src/native-entry.ts.eta +14 -0
- package/templates/gamemodule/src/sim-host.ts.eta +25 -0
- package/templates/gamemodule/src/sim-worker.ts.eta +24 -0
- package/templates/gamemodule/tsconfig.json.eta +1 -0
- package/templates/minimal/.gitignore.eta +5 -0
- package/templates/minimal/.vscode/extensions.json.eta +1 -0
- package/templates/minimal/.vscode/settings.json.eta +1 -0
- package/templates/minimal/.vscode/tasks.json.eta +1 -0
- package/templates/minimal/README.md.eta +32 -0
- package/templates/minimal/downdraft.config.json.eta +3 -0
- package/templates/minimal/package.json.eta +9 -0
- package/templates/minimal/src/game-module.ts.eta +44 -0
- package/templates/minimal/src/native-entry.ts.eta +14 -0
- package/templates/minimal/tsconfig.json.eta +1 -0
- package/templates/physics/.gitignore.eta +5 -0
- package/templates/physics/.vscode/extensions.json.eta +1 -0
- package/templates/physics/.vscode/settings.json.eta +1 -0
- package/templates/physics/.vscode/tasks.json.eta +1 -0
- package/templates/physics/README.md.eta +36 -0
- package/templates/physics/downdraft.config.json.eta +6 -0
- package/templates/physics/package.json.eta +9 -0
- package/templates/physics/src/game-module.ts.eta +29 -0
- package/templates/physics/src/native-entry.ts.eta +14 -0
- package/templates/physics/src/sim-host.ts.eta +19 -0
- package/templates/physics/src/sim-worker.ts.eta +155 -0
- package/templates/physics/tsconfig.json.eta +1 -0
package/src/assets.ts
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_CACHE_DIR,
|
|
3
|
+
MANIFEST_FILENAME,
|
|
4
|
+
createEmptyManifest, createLogger, packCacheKey,
|
|
5
|
+
validateManifest, type AssetManifest,
|
|
6
|
+
type AssetPackEntry,
|
|
7
|
+
type BlobStoreConfig
|
|
8
|
+
} from "@downdraft/engine";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
10
|
+
import { join, relative, resolve, sep } from "path";
|
|
11
|
+
import { parseArgs, print, renderHelp, type CommandSchema } from "./args";
|
|
12
|
+
import { createBlobStore } from "./blob-store-s3";
|
|
13
|
+
import { getCommand } from "./usage";
|
|
14
|
+
|
|
15
|
+
const log = createLogger();
|
|
16
|
+
|
|
17
|
+
const ASSET_EXTENSIONS = [
|
|
18
|
+
".png", ".jpg", ".jpeg", ".webp", ".ktx2",
|
|
19
|
+
".wav", ".mp3", ".ogg", ".flac",
|
|
20
|
+
".glb", ".gltf", ".obj", ".fbx",
|
|
21
|
+
".wgsl", ".json",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Per-subcommand schemas for the assets subcommands that take flags.
|
|
25
|
+
const ADD_STORE_SCHEMA: CommandSchema = {
|
|
26
|
+
positionals: [
|
|
27
|
+
{ name: "name", required: true, description: "Store name" },
|
|
28
|
+
{ name: "project", description: "Project path (default: current dir)" },
|
|
29
|
+
],
|
|
30
|
+
flags: [
|
|
31
|
+
{ name: "bucket", type: "string", required: true, description: "S3 bucket name" },
|
|
32
|
+
{ name: "endpoint", type: "string", description: "S3-compatible endpoint URL" },
|
|
33
|
+
{ name: "region", type: "string", default: "us-east-1", description: "AWS region" },
|
|
34
|
+
{ name: "path-style", type: "boolean", description: "Use path-style addressing" },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const ADD_PACK_SCHEMA: CommandSchema = {
|
|
39
|
+
positionals: [
|
|
40
|
+
{ name: "pack", required: true, description: "Pack name" },
|
|
41
|
+
{ name: "project", description: "Project path (default: current dir)" },
|
|
42
|
+
],
|
|
43
|
+
flags: [
|
|
44
|
+
{ name: "version", type: "string", default: "1.0.0", description: "Pack version" },
|
|
45
|
+
{ name: "store", type: "string", required: true, description: "Store name (must exist in manifest)" },
|
|
46
|
+
{ name: "path", type: "string", description: "Remote path prefix (defaults to pack name)" },
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const PUSH_SCHEMA: CommandSchema = {
|
|
51
|
+
positionals: [{ name: "project", description: "Project path (default: current dir)" }],
|
|
52
|
+
flags: [
|
|
53
|
+
{ name: "pack", type: "string", description: "Pack name to push (defaults to first pack)" },
|
|
54
|
+
{ name: "store", type: "string", description: "Store name to push to" },
|
|
55
|
+
{ name: "path", type: "string", description: "Remote path prefix override" },
|
|
56
|
+
{ name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const PULL_SCHEMA: CommandSchema = {
|
|
61
|
+
positionals: [{ name: "project", description: "Project path (default: current dir)" }],
|
|
62
|
+
flags: [
|
|
63
|
+
{ name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const LIST_SCHEMA: CommandSchema = {
|
|
68
|
+
positionals: [{ name: "project", description: "Project path (default: current dir)" }],
|
|
69
|
+
flags: [
|
|
70
|
+
{ name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const INIT_SCHEMA: CommandSchema = {
|
|
75
|
+
positionals: [{ name: "project", description: "Project path (default: current dir)" }],
|
|
76
|
+
flags: [],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export async function assets(args: string[]): Promise<void> {
|
|
80
|
+
const entry = getCommand("assets")!;
|
|
81
|
+
// The outer parse extracts the subcommand + project + --verbose.
|
|
82
|
+
const parsed = parseArgs(args, entry.schema);
|
|
83
|
+
if (parsed.help) {
|
|
84
|
+
print(renderHelp(entry.usage, entry.schema));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const subcommand = parsed.positionals[0];
|
|
89
|
+
const projectPath = parsed.positionals[1] ?? ".";
|
|
90
|
+
const verbose = parsed.flags.verbose as boolean;
|
|
91
|
+
|
|
92
|
+
switch (subcommand) {
|
|
93
|
+
case "pull": {
|
|
94
|
+
const sub = parseArgs(args.filter((a) => a !== "pull" && a !== projectPath), PULL_SCHEMA);
|
|
95
|
+
if (sub.help) { print(renderHelp("draft assets pull [project]", PULL_SCHEMA)); return; }
|
|
96
|
+
await pullAssets(projectPath, verbose);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "push": {
|
|
100
|
+
const sub = parseArgs(args.filter((a) => a !== "push" && a !== projectPath), PUSH_SCHEMA);
|
|
101
|
+
if (sub.help) { print(renderHelp("draft assets push [project]", PUSH_SCHEMA)); return; }
|
|
102
|
+
await pushAssets(projectPath, sub.flags, verbose);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case "list": {
|
|
106
|
+
const sub = parseArgs(args.filter((a) => a !== "list" && a !== projectPath), LIST_SCHEMA);
|
|
107
|
+
if (sub.help) { print(renderHelp("draft assets list [project]", LIST_SCHEMA)); return; }
|
|
108
|
+
await listAssets(projectPath, verbose);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "init": {
|
|
112
|
+
const sub = parseArgs(args.filter((a) => a !== "init" && a !== projectPath), INIT_SCHEMA);
|
|
113
|
+
if (sub.help) { print(renderHelp("draft assets init [project]", INIT_SCHEMA)); return; }
|
|
114
|
+
await initManifest(projectPath);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "add": {
|
|
118
|
+
const sub = parseArgs(args.slice(1), ADD_PACK_SCHEMA);
|
|
119
|
+
if (sub.help) { print(renderHelp("draft assets add <pack> [project]", ADD_PACK_SCHEMA)); return; }
|
|
120
|
+
const packName = sub.positionals[0];
|
|
121
|
+
const packProject = sub.positionals[1] ?? projectPath;
|
|
122
|
+
await addPackParsed(packProject, packName, sub.flags);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "add-store": {
|
|
126
|
+
const sub = parseArgs(args.slice(1), ADD_STORE_SCHEMA);
|
|
127
|
+
if (sub.help) { print(renderHelp("draft assets add-store <name> [project]", ADD_STORE_SCHEMA)); return; }
|
|
128
|
+
const storeName = sub.positionals[0];
|
|
129
|
+
const storeProject = sub.positionals[1] ?? projectPath;
|
|
130
|
+
await addStoreParsed(storeProject, storeName, sub.flags);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
print(renderHelp(entry.usage, entry.schema));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Manifest helpers ──────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
function loadManifest(projectPath: string): AssetManifest | null {
|
|
142
|
+
const manifestPath = resolve(projectPath, MANIFEST_FILENAME);
|
|
143
|
+
if (!existsSync(manifestPath)) {
|
|
144
|
+
log.error("assets", `No manifest found at ${manifestPath}. Run 'draft assets init' first.`);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const raw = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
148
|
+
if (!validateManifest(raw)) {
|
|
149
|
+
log.error("assets", `Invalid manifest format at ${manifestPath}`);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return raw;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function saveManifest(projectPath: string, manifest: AssetManifest): void {
|
|
156
|
+
const manifestPath = resolve(projectPath, MANIFEST_FILENAME);
|
|
157
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
158
|
+
log.info("assets", `Manifest saved → ${manifestPath}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getCacheDir(projectPath: string, manifest: AssetManifest): string {
|
|
162
|
+
const cacheDir = manifest.cacheDir ?? DEFAULT_CACHE_DIR;
|
|
163
|
+
return resolve(projectPath, cacheDir);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function resolveStoreConfig(manifest: AssetManifest, storeName: string): BlobStoreConfig | null {
|
|
167
|
+
const config = manifest.stores[storeName];
|
|
168
|
+
if (!config) {
|
|
169
|
+
log.error("assets", `Store "${storeName}" not found in manifest. Available: ${Object.keys(manifest.stores).join(", ") || "(none)"}`);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
// Override with env vars if not set in manifest
|
|
173
|
+
return {
|
|
174
|
+
...config,
|
|
175
|
+
accessKeyId: config.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID,
|
|
176
|
+
secretAccessKey: config.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── init ──────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
async function initManifest(projectPath: string): Promise<void> {
|
|
183
|
+
const manifestPath = resolve(projectPath, MANIFEST_FILENAME);
|
|
184
|
+
if (existsSync(manifestPath)) {
|
|
185
|
+
log.warn("assets", `Manifest already exists at ${manifestPath}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const manifest = createEmptyManifest();
|
|
189
|
+
saveManifest(projectPath, manifest);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── add-store ─────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
async function addStoreParsed(
|
|
195
|
+
projectPath: string,
|
|
196
|
+
name: string,
|
|
197
|
+
flags: Record<string, string | boolean | number | string[]>,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
if (!name) {
|
|
200
|
+
log.error("assets", "Usage: draft assets add-store <name> --bucket=<> --endpoint=<> --region=<>");
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const bucket = flags.bucket as string;
|
|
205
|
+
const endpoint = (flags.endpoint as string) || undefined;
|
|
206
|
+
const region = (flags.region as string) || "us-east-1";
|
|
207
|
+
const forcePathStyle = flags["path-style"] as boolean;
|
|
208
|
+
|
|
209
|
+
if (!bucket) {
|
|
210
|
+
log.error("assets", "--bucket=<> is required");
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const manifest = loadManifest(projectPath);
|
|
215
|
+
if (!manifest) return;
|
|
216
|
+
|
|
217
|
+
manifest.stores[name] = {
|
|
218
|
+
bucket,
|
|
219
|
+
endpoint,
|
|
220
|
+
region,
|
|
221
|
+
forcePathStyle,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
saveManifest(projectPath, manifest);
|
|
225
|
+
log.info("assets", `Added store "${name}" → ${endpoint ?? "AWS S3"} / ${bucket}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── add ────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
async function addPackParsed(
|
|
231
|
+
projectPath: string,
|
|
232
|
+
name: string,
|
|
233
|
+
flags: Record<string, string | boolean | number | string[]>,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
if (!name) {
|
|
236
|
+
log.error("assets", "Usage: draft assets add <pack-name> --version=<> --store=<> --path=<>");
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const version = (flags.version as string) || "1.0.0";
|
|
241
|
+
const store = flags.store as string;
|
|
242
|
+
const path = (flags.path as string) || name;
|
|
243
|
+
|
|
244
|
+
if (!store) {
|
|
245
|
+
log.error("assets", "--store=<> is required (must match a store name in manifest)");
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const manifest = loadManifest(projectPath);
|
|
250
|
+
if (!manifest) return;
|
|
251
|
+
|
|
252
|
+
const existing = manifest.packs.findIndex((p) => p.name === name);
|
|
253
|
+
const entry: AssetPackEntry = { name, version, store, path };
|
|
254
|
+
if (existing >= 0) {
|
|
255
|
+
manifest.packs[existing] = entry;
|
|
256
|
+
log.info("assets", `Updated pack "${name}" → ${store}/${path}@${version}`);
|
|
257
|
+
} else {
|
|
258
|
+
manifest.packs.push(entry);
|
|
259
|
+
log.info("assets", `Added pack "${name}" → ${store}/${path}@${version}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
saveManifest(projectPath, manifest);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── pull ───────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
async function pullAssets(projectPath: string, verbose: boolean): Promise<void> {
|
|
268
|
+
const manifest = loadManifest(projectPath);
|
|
269
|
+
if (!manifest) return;
|
|
270
|
+
|
|
271
|
+
const cacheDir = getCacheDir(projectPath, manifest);
|
|
272
|
+
if (!existsSync(cacheDir)) {
|
|
273
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (manifest.packs.length === 0) {
|
|
277
|
+
log.info("assets", "No packs in manifest. Use 'draft assets add' to add one.");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
log.info("assets", `
|
|
282
|
+
╔══════════════════════════════════════════╗
|
|
283
|
+
║ DownDraft — Asset Pull ║
|
|
284
|
+
╚══════════════════════════════════════════╝
|
|
285
|
+
`);
|
|
286
|
+
|
|
287
|
+
let totalFiles = 0;
|
|
288
|
+
let totalBytes = 0;
|
|
289
|
+
|
|
290
|
+
for (let _i = 0, _it = manifest.packs, _n = _it.length; _i < _n; _i++) { const pack = _it[_i];
|
|
291
|
+
const storeConfig = resolveStoreConfig(manifest, pack.store);
|
|
292
|
+
if (!storeConfig) continue;
|
|
293
|
+
|
|
294
|
+
const store = createBlobStore(storeConfig);
|
|
295
|
+
const prefix = pack.path.endsWith("/") ? pack.path : pack.path + "/";
|
|
296
|
+
|
|
297
|
+
log.info("assets", `Pulling pack: ${pack.name}@${pack.version} from ${pack.store}/${prefix}`);
|
|
298
|
+
|
|
299
|
+
let cursor: string | undefined;
|
|
300
|
+
let packFiles = 0;
|
|
301
|
+
let packBytes = 0;
|
|
302
|
+
|
|
303
|
+
do {
|
|
304
|
+
const result = await store.list({ prefix, cursor });
|
|
305
|
+
for (let _i = 0, _it = result.objects, _n = _it.length; _i < _n; _i++) { const obj = _it[_i];
|
|
306
|
+
const relPath = obj.key.slice(prefix.length);
|
|
307
|
+
if (!relPath) continue;
|
|
308
|
+
|
|
309
|
+
const cacheKey = packCacheKey(pack, relPath);
|
|
310
|
+
const destPath = join(cacheDir, pack.name, pack.version, ...relPath.split("/"));
|
|
311
|
+
|
|
312
|
+
if (existsSync(destPath)) {
|
|
313
|
+
if (verbose) log.debug("assets", ` cached: ${relPath}`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (destPath.includes(sep)) {
|
|
318
|
+
const dir = destPath.slice(0, destPath.lastIndexOf(sep));
|
|
319
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const data = await store.get(obj.key);
|
|
323
|
+
writeFileSync(destPath, data);
|
|
324
|
+
|
|
325
|
+
packFiles++;
|
|
326
|
+
packBytes += obj.size;
|
|
327
|
+
if (verbose) {
|
|
328
|
+
log.debug("assets", ` downloaded: ${relPath} (${formatBytes(obj.size)})`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
cursor = result.truncated ? result.cursor : undefined;
|
|
332
|
+
} while (cursor);
|
|
333
|
+
|
|
334
|
+
log.info("assets", ` ${pack.name}: ${packFiles} files, ${formatBytes(packBytes)}`);
|
|
335
|
+
totalFiles += packFiles;
|
|
336
|
+
totalBytes += packBytes;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
log.info("assets", `Pull complete: ${totalFiles} files, ${formatBytes(totalBytes)} → ${relative(projectPath, cacheDir)}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── push ───────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
async function pushAssets(
|
|
345
|
+
projectPath: string,
|
|
346
|
+
flags: Record<string, string | boolean | number | string[]>,
|
|
347
|
+
verbose: boolean,
|
|
348
|
+
): Promise<void> {
|
|
349
|
+
const manifest = loadManifest(projectPath);
|
|
350
|
+
if (!manifest) return;
|
|
351
|
+
|
|
352
|
+
const packName = (flags.pack as string) || undefined;
|
|
353
|
+
const storeName = (flags.store as string) || undefined;
|
|
354
|
+
const remotePath = (flags.path as string) || undefined;
|
|
355
|
+
|
|
356
|
+
const assetsDir = resolve(projectPath, "assets");
|
|
357
|
+
if (!existsSync(assetsDir)) {
|
|
358
|
+
log.error("assets", `No assets directory found at ${assetsDir}`);
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let storeConfig: BlobStoreConfig | null = null;
|
|
363
|
+
let pack: AssetPackEntry | undefined;
|
|
364
|
+
|
|
365
|
+
if (packName) {
|
|
366
|
+
pack = manifest.packs.find((p) => p.name === packName);
|
|
367
|
+
if (!pack) {
|
|
368
|
+
log.error("assets", `Pack "${packName}" not found in manifest`);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
storeConfig = resolveStoreConfig(manifest, pack.store);
|
|
372
|
+
if (!storeConfig) process.exit(1);
|
|
373
|
+
} else if (storeName) {
|
|
374
|
+
storeConfig = resolveStoreConfig(manifest, storeName);
|
|
375
|
+
if (!storeConfig) process.exit(1);
|
|
376
|
+
} else if (manifest.packs.length > 0) {
|
|
377
|
+
pack = manifest.packs[0];
|
|
378
|
+
storeConfig = resolveStoreConfig(manifest, pack.store);
|
|
379
|
+
if (!storeConfig) process.exit(1);
|
|
380
|
+
log.info("assets", `No --pack specified, using first pack: ${pack.name}`);
|
|
381
|
+
} else {
|
|
382
|
+
log.error("assets", "No packs in manifest and no --store specified. Use 'draft assets add' or specify --store=<>.");
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const prefix = remotePath ?? pack?.path ?? packName ?? "assets";
|
|
387
|
+
const normalizedPrefix = prefix.endsWith("/") ? prefix : prefix + "/";
|
|
388
|
+
|
|
389
|
+
log.info("assets", `
|
|
390
|
+
╔══════════════════════════════════════════╗
|
|
391
|
+
║ DownDraft — Asset Push ║
|
|
392
|
+
╚══════════════════════════════════════════╝
|
|
393
|
+
`);
|
|
394
|
+
|
|
395
|
+
log.info("assets", ` Source: ${assetsDir}`);
|
|
396
|
+
log.info("assets", ` Target: ${storeConfig.bucket}/${normalizedPrefix}`);
|
|
397
|
+
|
|
398
|
+
const store = createBlobStore(storeConfig);
|
|
399
|
+
const files = collectAssetFiles(assetsDir);
|
|
400
|
+
|
|
401
|
+
let totalFiles = 0;
|
|
402
|
+
let totalBytes = 0;
|
|
403
|
+
|
|
404
|
+
for (let _i = 0, _it = files, _n = _it.length; _i < _n; _i++) { const file = _it[_i];
|
|
405
|
+
const relPath = relative(assetsDir, file);
|
|
406
|
+
const key = normalizedPrefix + relPath.split(sep).join("/");
|
|
407
|
+
const data = readFileSync(file);
|
|
408
|
+
|
|
409
|
+
const ext = file.split(".").pop()?.toLowerCase() ?? "";
|
|
410
|
+
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
411
|
+
|
|
412
|
+
await store.put(key, new Uint8Array(data), { contentType });
|
|
413
|
+
totalFiles++;
|
|
414
|
+
totalBytes += data.length;
|
|
415
|
+
|
|
416
|
+
if (verbose) {
|
|
417
|
+
log.debug("assets", ` uploaded: ${relPath} (${formatBytes(data.length)})`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
log.info("assets", `Push complete: ${totalFiles} files, ${formatBytes(totalBytes)} → ${storeConfig.bucket}/${normalizedPrefix}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── list ───────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
async function listAssets(projectPath: string, verbose: boolean): Promise<void> {
|
|
427
|
+
const manifest = loadManifest(projectPath);
|
|
428
|
+
if (!manifest) return;
|
|
429
|
+
|
|
430
|
+
const cacheDir = getCacheDir(projectPath, manifest);
|
|
431
|
+
|
|
432
|
+
log.info("assets", `
|
|
433
|
+
╔══════════════════════════════════════════╗
|
|
434
|
+
║ DownDraft — Asset Status ║
|
|
435
|
+
╚══════════════════════════════════════════╝
|
|
436
|
+
`);
|
|
437
|
+
|
|
438
|
+
// Stores
|
|
439
|
+
log.info("assets", "Stores:");
|
|
440
|
+
if (Object.keys(manifest.stores).length === 0) {
|
|
441
|
+
log.info("assets", " (none — run 'draft assets add-store')");
|
|
442
|
+
}
|
|
443
|
+
for (const [name, config] of Object.entries(manifest.stores)) {
|
|
444
|
+
log.info("assets", ` ${name}: ${config.endpoint ?? "AWS S3"} / ${config.bucket}`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Packs
|
|
448
|
+
log.info("assets", "\nPacks:");
|
|
449
|
+
if (manifest.packs.length === 0) {
|
|
450
|
+
log.info("assets", " (none — run 'draft assets add')");
|
|
451
|
+
}
|
|
452
|
+
manifest.packs.forEach((pack) => {
|
|
453
|
+
const packCachePath = join(cacheDir, pack.name, pack.version);
|
|
454
|
+
let cachedFiles = 0;
|
|
455
|
+
let cachedBytes = 0;
|
|
456
|
+
if (existsSync(packCachePath)) {
|
|
457
|
+
const cached = collectAssetFiles(packCachePath);
|
|
458
|
+
cachedFiles = cached.length;
|
|
459
|
+
cachedBytes = cached.reduce((sum, f) => sum + statSync(f).size, 0);
|
|
460
|
+
}
|
|
461
|
+
log.info("assets", ` ${pack.name}@${pack.version} → ${pack.store}/${pack.path}`);
|
|
462
|
+
log.info("assets", ` cached: ${cachedFiles} files, ${formatBytes(cachedBytes)}`);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// Cache dir
|
|
466
|
+
log.info("assets", `\nCache: ${existsSync(cacheDir) ? relative(projectPath, cacheDir) : "(not created)"}`);
|
|
467
|
+
|
|
468
|
+
if (verbose && existsSync(cacheDir)) {
|
|
469
|
+
const allCached = collectAssetFiles(cacheDir);
|
|
470
|
+
log.debug("assets", ` total cached files: ${allCached.length}`);
|
|
471
|
+
allCached.forEach((f) => {
|
|
472
|
+
log.debug("assets", ` ${relative(cacheDir, f)}`);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ─── Utils ──────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
function collectAssetFiles(dir: string): string[] {
|
|
480
|
+
const results: string[] = [];
|
|
481
|
+
if (!existsSync(dir)) return results;
|
|
482
|
+
|
|
483
|
+
const entries = readdirSync(dir);
|
|
484
|
+
for (let _i = 0, _it = entries, _n = _it.length; _i < _n; _i++) { const entry = _it[_i];
|
|
485
|
+
if (entry === "node_modules" || entry === ".git") continue;
|
|
486
|
+
const fullPath = join(dir, entry);
|
|
487
|
+
const stat = statSync(fullPath);
|
|
488
|
+
if (stat.isDirectory()) {
|
|
489
|
+
results.push(...collectAssetFiles(fullPath));
|
|
490
|
+
} else {
|
|
491
|
+
const ext = "." + (entry.split(".").pop() ?? "");
|
|
492
|
+
if (ASSET_EXTENSIONS.includes(ext.toLowerCase())) {
|
|
493
|
+
results.push(fullPath);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return results;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function formatBytes(bytes: number): string {
|
|
501
|
+
if (bytes === 0) return "0 B";
|
|
502
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
503
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
504
|
+
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
508
|
+
png: "image/png",
|
|
509
|
+
jpg: "image/jpeg",
|
|
510
|
+
jpeg: "image/jpeg",
|
|
511
|
+
webp: "image/webp",
|
|
512
|
+
ktx2: "image/ktx2",
|
|
513
|
+
wav: "audio/wav",
|
|
514
|
+
mp3: "audio/mpeg",
|
|
515
|
+
ogg: "audio/ogg",
|
|
516
|
+
flac: "audio/flac",
|
|
517
|
+
glb: "model/gltf-binary",
|
|
518
|
+
gltf: "model/gltf+json",
|
|
519
|
+
obj: "model/obj",
|
|
520
|
+
fbx: "model/fbx",
|
|
521
|
+
wgsl: "text/wgsl",
|
|
522
|
+
json: "application/json",
|
|
523
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BlobGetOptions,
|
|
3
|
+
BlobListOptions,
|
|
4
|
+
BlobListResult,
|
|
5
|
+
BlobObject,
|
|
6
|
+
BlobPutOptions,
|
|
7
|
+
BlobStore,
|
|
8
|
+
BlobStoreConfig,
|
|
9
|
+
} from "@downdraft/engine";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* S3-compatible blob store adapter.
|
|
13
|
+
* Works with AWS S3, MinIO, Cloudflare R2, Backblaze B2, etc.
|
|
14
|
+
* Uses the AWS SDK v3 S3 client with configurable endpoint.
|
|
15
|
+
*/
|
|
16
|
+
export class S3BlobStore implements BlobStore {
|
|
17
|
+
private client: any;
|
|
18
|
+
private bucket: string;
|
|
19
|
+
|
|
20
|
+
constructor(config: BlobStoreConfig) {
|
|
21
|
+
this.bucket = config.bucket;
|
|
22
|
+
// Dynamic import to avoid hard dependency at module load
|
|
23
|
+
// The CLI package declares @aws-sdk/client-s3 as a dependency
|
|
24
|
+
const endpoint = config.endpoint;
|
|
25
|
+
const region = config.region ?? "us-east-1";
|
|
26
|
+
const forcePathStyle = config.forcePathStyle ?? true;
|
|
27
|
+
|
|
28
|
+
// We use a lazy require pattern so the SDK is only loaded when needed
|
|
29
|
+
const mod = require("@aws-sdk/client-s3");
|
|
30
|
+
const S3Client = mod.S3Client;
|
|
31
|
+
this.client = new S3Client({
|
|
32
|
+
region,
|
|
33
|
+
endpoint,
|
|
34
|
+
forcePathStyle,
|
|
35
|
+
credentials: config.accessKeyId
|
|
36
|
+
? {
|
|
37
|
+
accessKeyId: config.accessKeyId,
|
|
38
|
+
secretAccessKey: config.secretAccessKey!,
|
|
39
|
+
}
|
|
40
|
+
: undefined,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async get(key: string, options?: BlobGetOptions): Promise<Uint8Array> {
|
|
45
|
+
const { GetObjectCommand } = require("@aws-sdk/client-s3");
|
|
46
|
+
const cmd = new GetObjectCommand({
|
|
47
|
+
Bucket: this.bucket,
|
|
48
|
+
Key: key,
|
|
49
|
+
Range: options?.range
|
|
50
|
+
? `bytes=${options.range.start}-${options.range.end}`
|
|
51
|
+
: undefined,
|
|
52
|
+
});
|
|
53
|
+
const resp = await this.client.send(cmd);
|
|
54
|
+
const buf = await resp.Body.transformToByteArray();
|
|
55
|
+
return buf as Uint8Array;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async put(
|
|
59
|
+
key: string,
|
|
60
|
+
data: Uint8Array,
|
|
61
|
+
options?: BlobPutOptions,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
const { PutObjectCommand } = require("@aws-sdk/client-s3");
|
|
64
|
+
const cmd = new PutObjectCommand({
|
|
65
|
+
Bucket: this.bucket,
|
|
66
|
+
Key: key,
|
|
67
|
+
Body: data,
|
|
68
|
+
ContentType: options?.contentType ?? "application/octet-stream",
|
|
69
|
+
Metadata: options?.metadata,
|
|
70
|
+
});
|
|
71
|
+
await this.client.send(cmd);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async delete(key: string): Promise<void> {
|
|
75
|
+
const { DeleteObjectCommand } = require("@aws-sdk/client-s3");
|
|
76
|
+
const cmd = new DeleteObjectCommand({
|
|
77
|
+
Bucket: this.bucket,
|
|
78
|
+
Key: key,
|
|
79
|
+
});
|
|
80
|
+
await this.client.send(cmd);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async exists(key: string): Promise<boolean> {
|
|
84
|
+
const { HeadObjectCommand } = require("@aws-sdk/client-s3");
|
|
85
|
+
try {
|
|
86
|
+
await this.client.send(
|
|
87
|
+
new HeadObjectCommand({ Bucket: this.bucket, Key: key }),
|
|
88
|
+
);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async list(options?: BlobListOptions): Promise<BlobListResult> {
|
|
96
|
+
const { ListObjectsV2Command } = require("@aws-sdk/client-s3");
|
|
97
|
+
const cmd = new ListObjectsV2Command({
|
|
98
|
+
Bucket: this.bucket,
|
|
99
|
+
Prefix: options?.prefix,
|
|
100
|
+
MaxKeys: options?.maxKeys ?? 1000,
|
|
101
|
+
ContinuationToken: options?.cursor,
|
|
102
|
+
});
|
|
103
|
+
const resp = await this.client.send(cmd);
|
|
104
|
+
const objects: BlobObject[] = (resp.Contents ?? []).map(
|
|
105
|
+
(o: any) => ({
|
|
106
|
+
key: o.Key,
|
|
107
|
+
size: o.Size,
|
|
108
|
+
etag: o.ETag?.replace(/"/g, ""),
|
|
109
|
+
lastModified: o.LastModified ? new Date(o.LastModified) : undefined,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
return {
|
|
113
|
+
objects,
|
|
114
|
+
truncated: resp.IsTruncated ?? false,
|
|
115
|
+
cursor: resp.NextContinuationToken,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async getMeta(key: string): Promise<BlobObject | null> {
|
|
120
|
+
const { HeadObjectCommand } = require("@aws-sdk/client-s3");
|
|
121
|
+
try {
|
|
122
|
+
const resp = await this.client.send(
|
|
123
|
+
new HeadObjectCommand({ Bucket: this.bucket, Key: key }),
|
|
124
|
+
);
|
|
125
|
+
return {
|
|
126
|
+
key,
|
|
127
|
+
size: resp.ContentLength ?? 0,
|
|
128
|
+
etag: resp.ETag?.replace(/"/g, ""),
|
|
129
|
+
lastModified: resp.LastModified
|
|
130
|
+
? new Date(resp.LastModified)
|
|
131
|
+
: undefined,
|
|
132
|
+
};
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create a BlobStore from a BlobStoreConfig.
|
|
141
|
+
* Currently only S3-compatible, but extensible to other backends.
|
|
142
|
+
*/
|
|
143
|
+
export function createBlobStore(config: BlobStoreConfig): BlobStore {
|
|
144
|
+
return new S3BlobStore(config);
|
|
145
|
+
}
|