create-macostack 0.1.1 → 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/bin/create-macostack.ts +248 -405
- package/package.json +1 -1
package/bin/create-macostack.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import {
|
|
3
3
|
cancel,
|
|
4
|
+
confirm,
|
|
4
5
|
intro,
|
|
5
6
|
isCancel,
|
|
7
|
+
log,
|
|
6
8
|
multiselect,
|
|
7
9
|
note,
|
|
8
10
|
outro,
|
|
@@ -10,9 +12,11 @@ import {
|
|
|
10
12
|
text,
|
|
11
13
|
} from "@clack/prompts";
|
|
12
14
|
import {
|
|
15
|
+
closeSync,
|
|
13
16
|
existsSync,
|
|
14
17
|
mkdirSync,
|
|
15
18
|
mkdtempSync,
|
|
19
|
+
openSync,
|
|
16
20
|
readdirSync,
|
|
17
21
|
rmSync,
|
|
18
22
|
} from "node:fs";
|
|
@@ -20,52 +24,52 @@ import { tmpdir } from "node:os";
|
|
|
20
24
|
import { join, resolve } from "node:path";
|
|
21
25
|
import { parseArgs } from "node:util";
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
// create-macostack — start a new app from the macostack templates.
|
|
28
|
+
//
|
|
29
|
+
// bun create macostack my-app
|
|
30
|
+
//
|
|
31
|
+
// It asks what your app needs (backend API, web app, or both), downloads each
|
|
32
|
+
// template WITHOUT its git history, installs dependencies, and hands you over
|
|
33
|
+
// to each template's own setup wizard. The orchestrator stays deliberately
|
|
34
|
+
// dumb: it never knows what modules exist — each template owns its questions.
|
|
35
|
+
|
|
36
|
+
//////////////////////////////////////////////////////////////////
|
|
37
|
+
// CONFIG
|
|
38
|
+
//////////////////////////////////////////////////////////////////
|
|
39
|
+
|
|
40
|
+
const DEFAULT_OWNER = Bun.env.MACOSTACK_GITHUB_OWNER ?? "macobits";
|
|
41
|
+
const DEFAULT_REF = Bun.env.MACOSTACK_REF ?? "main";
|
|
42
|
+
|
|
43
|
+
type Part = {
|
|
44
|
+
id: "server" | "client";
|
|
45
|
+
folder: string;
|
|
33
46
|
repo: string;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
type ScaffoldConfig = {
|
|
37
|
-
owner: string;
|
|
38
|
-
ref: string;
|
|
39
|
-
token: string;
|
|
40
|
-
targetRoot: string;
|
|
41
|
-
targetName: string;
|
|
42
|
-
skipInstall: boolean;
|
|
43
|
-
skipSetup: boolean;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
type DownloadTemplateProps = {
|
|
47
|
-
config: ScaffoldConfig;
|
|
48
|
-
template: TemplateConfig;
|
|
49
|
-
destination: string;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
type RunCommandProps = {
|
|
53
|
-
command: string[];
|
|
54
|
-
cwd: string;
|
|
55
47
|
label: string;
|
|
56
|
-
|
|
48
|
+
hint: string;
|
|
57
49
|
};
|
|
58
50
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
51
|
+
const PARTS: Part[] = [
|
|
52
|
+
{
|
|
53
|
+
id: "server",
|
|
54
|
+
folder: "server",
|
|
55
|
+
repo: Bun.env.MACOSTACK_SERVER_REPO ?? "macostack-server",
|
|
56
|
+
label: "Backend API",
|
|
57
|
+
hint: "Bun + Hono + Postgres — auth included",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "client",
|
|
61
|
+
folder: "client",
|
|
62
|
+
repo: Bun.env.MACOSTACK_CLIENT_REPO ?? "macostack-client",
|
|
63
|
+
label: "Web app",
|
|
64
|
+
hint: "the frontend",
|
|
65
|
+
},
|
|
66
|
+
];
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const DEFAULT_CLIENT_REPO = "macostack-client";
|
|
68
|
+
//////////////////////////////////////////////////////////////////
|
|
69
|
+
// FLAGS
|
|
70
|
+
//////////////////////////////////////////////////////////////////
|
|
67
71
|
|
|
68
|
-
const
|
|
72
|
+
const parsed = parseArgs({
|
|
69
73
|
args: Bun.argv.slice(2),
|
|
70
74
|
allowPositionals: true,
|
|
71
75
|
options: {
|
|
@@ -81,36 +85,35 @@ const parsedArgs = parseArgs({
|
|
|
81
85
|
"skip-setup": { type: "boolean", default: false },
|
|
82
86
|
},
|
|
83
87
|
});
|
|
88
|
+
const flags = parsed.values;
|
|
84
89
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const HELP_TEXT = `create-macostack
|
|
90
|
+
if (flags.help) {
|
|
91
|
+
console.log(`create-macostack — start a new app from the macostack templates
|
|
88
92
|
|
|
89
93
|
Usage:
|
|
90
|
-
bun create macostack <
|
|
91
|
-
bun run dev <venture-folder> [flags]
|
|
94
|
+
bun create macostack <app-name>
|
|
92
95
|
|
|
93
96
|
Flags:
|
|
94
|
-
--server
|
|
95
|
-
--
|
|
96
|
-
--owner <
|
|
97
|
-
--ref <ref>
|
|
98
|
-
--server-repo <
|
|
99
|
-
--client-repo <
|
|
100
|
-
--skip-install
|
|
101
|
-
--skip-setup
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
`;
|
|
108
|
-
|
|
109
|
-
if (flags.help === true) {
|
|
110
|
-
console.log(HELP_TEXT);
|
|
97
|
+
--server / --client scaffold only that part (default: it asks)
|
|
98
|
+
--yes no questions: both parts, template defaults
|
|
99
|
+
--owner <org> GitHub owner (default: ${DEFAULT_OWNER})
|
|
100
|
+
--ref <ref> branch or tag to pull (default: ${DEFAULT_REF})
|
|
101
|
+
--server-repo <name> server template repo
|
|
102
|
+
--client-repo <name> client template repo
|
|
103
|
+
--skip-install don't run bun install
|
|
104
|
+
--skip-setup don't run each template's setup wizard
|
|
105
|
+
|
|
106
|
+
Auth (templates are private):
|
|
107
|
+
export GITHUB_TOKEN=github_pat_… or gh auth login
|
|
108
|
+
(fine-grained token, Contents: Read-only is enough)
|
|
109
|
+
`);
|
|
111
110
|
process.exit(0);
|
|
112
111
|
}
|
|
113
112
|
|
|
113
|
+
//////////////////////////////////////////////////////////////////
|
|
114
|
+
// HELPERS
|
|
115
|
+
//////////////////////////////////////////////////////////////////
|
|
116
|
+
|
|
114
117
|
const bail = (message: string): never => {
|
|
115
118
|
cancel(message);
|
|
116
119
|
process.exit(1);
|
|
@@ -118,393 +121,233 @@ const bail = (message: string): never => {
|
|
|
118
121
|
|
|
119
122
|
const answered = <T>(value: T | symbol): T => {
|
|
120
123
|
if (isCancel(value)) {
|
|
121
|
-
cancel("
|
|
124
|
+
cancel("No problem — nothing was created.");
|
|
122
125
|
process.exit(0);
|
|
123
126
|
}
|
|
124
|
-
|
|
125
127
|
return value as T;
|
|
126
128
|
};
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
) => {
|
|
132
|
-
|
|
133
|
-
return
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return fallback;
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const getBooleanFlag = (value: string | boolean | undefined) => value === true;
|
|
140
|
-
|
|
141
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
142
|
-
typeof value === "object" && value !== null;
|
|
143
|
-
|
|
144
|
-
const isValidDirectoryName = (value: string) =>
|
|
145
|
-
/^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
146
|
-
|
|
147
|
-
const isStackPart = (value: string): value is StackPart =>
|
|
148
|
-
value === STACK_PART.SERVER || value === STACK_PART.CLIENT;
|
|
149
|
-
|
|
150
|
-
const readPackageJson = async (directory: string): Promise<PackageJson | null> => {
|
|
151
|
-
const packagePath = resolve(directory, "package.json");
|
|
152
|
-
if (!existsSync(packagePath)) {
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const value: unknown = await Bun.file(packagePath).json();
|
|
157
|
-
if (!isRecord(value)) {
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const scriptsValue = value.scripts;
|
|
162
|
-
if (!isRecord(scriptsValue)) {
|
|
163
|
-
return {};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const scripts = Object.fromEntries(
|
|
167
|
-
Object.entries(scriptsValue).filter(
|
|
168
|
-
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
169
|
-
),
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
return { scripts };
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
const getGhToken = (): string | null => {
|
|
176
|
-
const result = Bun.spawnSync(["gh", "auth", "token"], {
|
|
177
|
-
stdout: "pipe",
|
|
178
|
-
stderr: "pipe",
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
if (result.exitCode !== 0) {
|
|
130
|
+
// The setup wizards downstream are interactive. Under `bun create` / `bunx`,
|
|
131
|
+
// stdin often arrives as a pipe (not a TTY) even though a human is right
|
|
132
|
+
// there — so we hand children the real terminal via /dev/tty.
|
|
133
|
+
const openTty = (): number | null => {
|
|
134
|
+
try {
|
|
135
|
+
return openSync("/dev/tty", "r");
|
|
136
|
+
} catch {
|
|
182
137
|
return null;
|
|
183
138
|
}
|
|
184
|
-
|
|
185
|
-
const token = result.stdout.toString().trim();
|
|
186
|
-
return token.length > 0 ? token : null;
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const getGithubToken = (): string | null => {
|
|
190
|
-
const envToken = Bun.env.GITHUB_TOKEN ?? Bun.env.GH_TOKEN;
|
|
191
|
-
if (envToken && envToken.length > 0) {
|
|
192
|
-
return envToken;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return getGhToken();
|
|
196
139
|
};
|
|
197
140
|
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
if (typeof token === "string" && token.length > 0) {
|
|
201
|
-
return token;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return bail(
|
|
205
|
-
"Private templates require GitHub auth. Set GITHUB_TOKEN or run `gh auth login`. " +
|
|
206
|
-
"Use a fine-grained token with Contents: Read-only.",
|
|
207
|
-
);
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const ensureEmptyDirectory = (directory: string, label: string) => {
|
|
211
|
-
if (!existsSync(directory)) {
|
|
212
|
-
mkdirSync(directory, { recursive: true });
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const entries = readdirSync(directory).filter((entry) => entry !== ".DS_Store");
|
|
217
|
-
if (entries.length > 0) {
|
|
218
|
-
bail(`${label} already exists and is not empty: ${directory}`);
|
|
219
|
-
}
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const runCommand = ({
|
|
223
|
-
command,
|
|
224
|
-
cwd,
|
|
225
|
-
label,
|
|
226
|
-
allowFailure = false,
|
|
227
|
-
}: RunCommandProps) => {
|
|
141
|
+
const run = (command: string[], cwd: string) => {
|
|
142
|
+
const ttyFd = openTty();
|
|
228
143
|
const result = Bun.spawnSync(command, {
|
|
229
144
|
cwd,
|
|
230
|
-
stdin: "inherit",
|
|
145
|
+
stdin: ttyFd ?? "inherit",
|
|
231
146
|
stdout: "inherit",
|
|
232
147
|
stderr: "inherit",
|
|
233
148
|
});
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return result.exitCode;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
bail(`${label} failed with exit code ${result.exitCode}.`);
|
|
149
|
+
if (ttyFd !== null) closeSync(ttyFd);
|
|
150
|
+
return result.exitCode;
|
|
240
151
|
};
|
|
241
152
|
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const archivePath = join(temporaryDirectory, `${template.part}.tar.gz`);
|
|
249
|
-
const tarballUrl = `https://api.github.com/repos/${config.owner}/${template.repo}/tarball/${config.ref}`;
|
|
250
|
-
|
|
251
|
-
try {
|
|
252
|
-
const response = await fetch(tarballUrl, {
|
|
253
|
-
headers: {
|
|
254
|
-
Accept: "application/vnd.github+json",
|
|
255
|
-
Authorization: `Bearer ${config.token}`,
|
|
256
|
-
"User-Agent": "create-macostack",
|
|
257
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
258
|
-
},
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
if (!response.ok) {
|
|
262
|
-
const details = await response.text();
|
|
263
|
-
bail(
|
|
264
|
-
`GitHub refused ${config.owner}/${template.repo}@${config.ref} (${response.status}). ` +
|
|
265
|
-
`Check GITHUB_TOKEN permissions. ${details.slice(0, 240)}`,
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
await Bun.write(archivePath, await response.arrayBuffer());
|
|
270
|
-
ensureEmptyDirectory(destination, template.directory);
|
|
271
|
-
runCommand({
|
|
272
|
-
command: [
|
|
273
|
-
"tar",
|
|
274
|
-
"-xzf",
|
|
275
|
-
archivePath,
|
|
276
|
-
"-C",
|
|
277
|
-
destination,
|
|
278
|
-
"--strip-components=1",
|
|
279
|
-
],
|
|
280
|
-
cwd: destination,
|
|
281
|
-
label: `Extract ${template.part}`,
|
|
282
|
-
});
|
|
283
|
-
} finally {
|
|
284
|
-
rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
285
|
-
}
|
|
153
|
+
const githubToken = (): string | null => {
|
|
154
|
+
const fromEnv = Bun.env.GITHUB_TOKEN ?? Bun.env.GH_TOKEN;
|
|
155
|
+
if (fromEnv) return fromEnv;
|
|
156
|
+
const gh = Bun.spawnSync(["gh", "auth", "token"], { stdout: "pipe", stderr: "pipe" });
|
|
157
|
+
const token = gh.exitCode === 0 ? gh.stdout.toString().trim() : "";
|
|
158
|
+
return token || null;
|
|
286
159
|
};
|
|
287
160
|
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
cwd: directory,
|
|
292
|
-
label: `bun install in ${template.part}`,
|
|
293
|
-
});
|
|
294
|
-
};
|
|
161
|
+
const isEmptyDir = (dir: string) =>
|
|
162
|
+
!existsSync(dir) ||
|
|
163
|
+
readdirSync(dir).filter((e) => e !== ".DS_Store").length === 0;
|
|
295
164
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
note(`No setup script found in ${template.directory}; skipping delegation.`, template.part);
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
165
|
+
//////////////////////////////////////////////////////////////////
|
|
166
|
+
// WIZARD
|
|
167
|
+
//////////////////////////////////////////////////////////////////
|
|
302
168
|
|
|
303
|
-
|
|
304
|
-
command: ["bun", "run", "setup"],
|
|
305
|
-
cwd: directory,
|
|
306
|
-
label: `setup in ${template.part}`,
|
|
307
|
-
});
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
const ensureGitRepository = (directory: string, template: TemplateConfig) => {
|
|
311
|
-
if (existsSync(resolve(directory, ".git"))) {
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
169
|
+
intro("create-macostack");
|
|
314
170
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
cwd: directory,
|
|
323
|
-
label: `git add in ${template.part}`,
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
const commitExitCode = runCommand({
|
|
327
|
-
command: ["git", "commit", "-m", "init from template"],
|
|
328
|
-
cwd: directory,
|
|
329
|
-
label: `git commit in ${template.part}`,
|
|
330
|
-
allowFailure: true,
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
if (commitExitCode !== 0) {
|
|
334
|
-
note(
|
|
335
|
-
`Fresh ${template.part} repo created, but initial commit failed. ` +
|
|
336
|
-
`Run: cd ${template.directory} && git add -A && git commit -m "init"`,
|
|
337
|
-
"Git",
|
|
338
|
-
);
|
|
171
|
+
// 1. App name (positional arg or prompt).
|
|
172
|
+
const positional = parsed.positionals[0];
|
|
173
|
+
const validName = (v: string) => /^[a-z0-9][a-z0-9-]*$/.test(v);
|
|
174
|
+
let appName: string;
|
|
175
|
+
if (positional) {
|
|
176
|
+
if (!validName(positional)) {
|
|
177
|
+
bail("App names use lowercase letters, digits and dashes — e.g. soccer-app");
|
|
339
178
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
selected.push(STACK_PART.SERVER);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
if (getBooleanFlag(flags.client)) {
|
|
350
|
-
selected.push(STACK_PART.CLIENT);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
return selected;
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
const resolveTargetName = async () => {
|
|
357
|
-
const positionalTarget = parsedArgs.positionals[0];
|
|
358
|
-
if (positionalTarget) {
|
|
359
|
-
if (!isValidDirectoryName(positionalTarget)) {
|
|
360
|
-
bail("Project folder must use lowercase letters, digits and dashes only.");
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
return positionalTarget;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (getBooleanFlag(flags.yes)) {
|
|
367
|
-
bail("Missing project folder. Usage: bun create macostack my-venture");
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const value = answered(
|
|
179
|
+
appName = positional;
|
|
180
|
+
} else if (flags.yes) {
|
|
181
|
+
bail("Tell me the app name: bun create macostack my-app --yes");
|
|
182
|
+
throw ""; // unreachable
|
|
183
|
+
} else {
|
|
184
|
+
appName = answered(
|
|
371
185
|
await text({
|
|
372
|
-
message: "
|
|
373
|
-
placeholder: "
|
|
374
|
-
validate: (
|
|
375
|
-
|
|
376
|
-
? undefined
|
|
377
|
-
: "lowercase letters, digits and dashes only",
|
|
186
|
+
message: "What should we call your app?",
|
|
187
|
+
placeholder: "soccer-app",
|
|
188
|
+
validate: (v) =>
|
|
189
|
+
validName(v ?? "") ? undefined : "lowercase letters, digits and dashes — e.g. soccer-app",
|
|
378
190
|
}),
|
|
379
191
|
);
|
|
192
|
+
}
|
|
380
193
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const selectedFromFlags = getSelectedPartsFromFlags();
|
|
386
|
-
if (selectedFromFlags.length > 0) {
|
|
387
|
-
return selectedFromFlags;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (getBooleanFlag(flags.yes)) {
|
|
391
|
-
return [STACK_PART.SERVER, STACK_PART.CLIENT];
|
|
392
|
-
}
|
|
194
|
+
const targetRoot = resolve(process.cwd(), appName);
|
|
195
|
+
if (!isEmptyDir(targetRoot)) {
|
|
196
|
+
bail(`The folder ./${appName} already exists and isn't empty — pick another name or clear it first.`);
|
|
197
|
+
}
|
|
393
198
|
|
|
394
|
-
|
|
199
|
+
// 2. Which parts?
|
|
200
|
+
let selected: Part[];
|
|
201
|
+
if (flags.server || flags.client) {
|
|
202
|
+
selected = PARTS.filter((p) => flags[p.id]);
|
|
203
|
+
} else if (flags.yes) {
|
|
204
|
+
selected = PARTS;
|
|
205
|
+
} else {
|
|
206
|
+
const picked = answered(
|
|
395
207
|
await multiselect({
|
|
396
|
-
message:
|
|
397
|
-
options:
|
|
398
|
-
|
|
399
|
-
{ value: STACK_PART.CLIENT, label: "client" },
|
|
400
|
-
],
|
|
401
|
-
initialValues: [STACK_PART.SERVER, STACK_PART.CLIENT],
|
|
208
|
+
message: `What does ${appName} need? (space to pick, enter to continue)`,
|
|
209
|
+
options: PARTS.map((p) => ({ value: p.id, label: p.label, hint: p.hint })),
|
|
210
|
+
initialValues: PARTS.map((p) => p.id),
|
|
402
211
|
required: true,
|
|
403
212
|
}),
|
|
404
213
|
);
|
|
214
|
+
selected = PARTS.filter((p) => picked.includes(p.id));
|
|
215
|
+
}
|
|
405
216
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const serverRepo = getStringFlag(
|
|
416
|
-
flags["server-repo"],
|
|
417
|
-
Bun.env.MACOSTACK_SERVER_REPO ?? DEFAULT_SERVER_REPO,
|
|
418
|
-
);
|
|
419
|
-
const clientRepo = getStringFlag(
|
|
420
|
-
flags["client-repo"],
|
|
421
|
-
Bun.env.MACOSTACK_CLIENT_REPO ?? DEFAULT_CLIENT_REPO,
|
|
217
|
+
// 3. GitHub access — checked before anything is created.
|
|
218
|
+
const owner = flags.owner ?? DEFAULT_OWNER;
|
|
219
|
+
const ref = flags.ref ?? DEFAULT_REF;
|
|
220
|
+
const token = githubToken();
|
|
221
|
+
if (!token) {
|
|
222
|
+
bail(
|
|
223
|
+
"I need GitHub access to download the templates (they're private).\n" +
|
|
224
|
+
" Easiest: gh auth login\n" +
|
|
225
|
+
" Or: export GITHUB_TOKEN=github_pat_… (Contents: Read-only)",
|
|
422
226
|
);
|
|
227
|
+
throw ""; // unreachable
|
|
228
|
+
}
|
|
229
|
+
for (const p of selected) {
|
|
230
|
+
p.repo = (p.id === "server" ? flags["server-repo"] : flags["client-repo"]) ?? p.repo;
|
|
231
|
+
}
|
|
423
232
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
233
|
+
// Preflight every selected template so a typo'd repo or missing access fails
|
|
234
|
+
// BEFORE any folders exist.
|
|
235
|
+
const gh = (path: string) =>
|
|
236
|
+
fetch(`https://api.github.com${path}`, {
|
|
237
|
+
headers: {
|
|
238
|
+
Accept: "application/vnd.github+json",
|
|
239
|
+
Authorization: `Bearer ${token}`,
|
|
240
|
+
"User-Agent": "create-macostack",
|
|
241
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
429
242
|
},
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
243
|
+
});
|
|
244
|
+
for (const p of selected) {
|
|
245
|
+
const res = await gh(`/repos/${owner}/${p.repo}`);
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
bail(
|
|
248
|
+
`Can't reach ${owner}/${p.repo} (HTTP ${res.status}).\n` +
|
|
249
|
+
" Does the repo exist, and does your token have read access to it?",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
436
253
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
};
|
|
254
|
+
//////////////////////////////////////////////////////////////////
|
|
255
|
+
// SCAFFOLD
|
|
256
|
+
//////////////////////////////////////////////////////////////////
|
|
441
257
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const s = spinner();
|
|
258
|
+
mkdirSync(targetRoot, { recursive: true });
|
|
259
|
+
const ready: Part[] = [];
|
|
445
260
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
s
|
|
261
|
+
for (const p of selected) {
|
|
262
|
+
const dest = resolve(targetRoot, p.folder);
|
|
263
|
+
const s = spinner();
|
|
449
264
|
|
|
450
|
-
|
|
451
|
-
|
|
265
|
+
// Download the tarball — no git history comes with it, so each part starts
|
|
266
|
+
// life fully detached from the template.
|
|
267
|
+
s.start(`${p.label} — downloading ${owner}/${p.repo}@${ref}`);
|
|
268
|
+
const tmp = mkdtempSync(join(tmpdir(), "create-macostack-"));
|
|
269
|
+
try {
|
|
270
|
+
const res = await gh(`/repos/${owner}/${p.repo}/tarball/${ref}`);
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
s.stop(`${p.label} — download failed`);
|
|
273
|
+
bail(`GitHub refused the download (HTTP ${res.status}). Try again, or check --ref ${ref}.`);
|
|
274
|
+
}
|
|
275
|
+
const archive = join(tmp, "template.tar.gz");
|
|
276
|
+
await Bun.write(archive, await res.arrayBuffer());
|
|
277
|
+
mkdirSync(dest, { recursive: true });
|
|
278
|
+
const tar = Bun.spawnSync(
|
|
279
|
+
["tar", "-xzf", archive, "-C", dest, "--strip-components=1"],
|
|
280
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
281
|
+
);
|
|
282
|
+
if (tar.exitCode !== 0) {
|
|
283
|
+
s.stop(`${p.label} — extract failed`);
|
|
284
|
+
bail(`Couldn't unpack the template: ${tar.stderr.toString().slice(0, 200)}`);
|
|
285
|
+
}
|
|
286
|
+
} finally {
|
|
287
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
s.stop(`${p.label} — downloaded into ${appName}/${p.folder}`);
|
|
290
|
+
|
|
291
|
+
if (!flags["skip-install"]) {
|
|
292
|
+
const s2 = spinner();
|
|
293
|
+
s2.start(`${p.label} — installing dependencies`);
|
|
294
|
+
const code = Bun.spawnSync(["bun", "install"], { cwd: dest, stdout: "pipe", stderr: "pipe" }).exitCode;
|
|
295
|
+
s2.stop(
|
|
296
|
+
code === 0
|
|
297
|
+
? `${p.label} — dependencies installed`
|
|
298
|
+
: `${p.label} — bun install had issues (run it again inside ${appName}/${p.folder})`,
|
|
299
|
+
);
|
|
452
300
|
}
|
|
453
301
|
|
|
454
|
-
|
|
455
|
-
|
|
302
|
+
// Hand over to the template's own wizard — it knows its modules, we don't.
|
|
303
|
+
const pkg = existsSync(resolve(dest, "package.json"))
|
|
304
|
+
? await Bun.file(resolve(dest, "package.json")).json()
|
|
305
|
+
: null;
|
|
306
|
+
if (!flags["skip-setup"] && pkg?.scripts?.setup) {
|
|
307
|
+
log.step(`Now shaping the ${p.label.toLowerCase()} — its own wizard takes it from here:`);
|
|
308
|
+
const args = flags.yes
|
|
309
|
+
? ["run", "setup", "--yes", "--name", appName]
|
|
310
|
+
: ["run", "setup", "--name", appName];
|
|
311
|
+
const code = run(["bun", ...args], dest);
|
|
312
|
+
if (code !== 0) {
|
|
313
|
+
note(
|
|
314
|
+
`The ${p.folder} wizard didn't finish. Everything downloaded fine — resume with:\n cd ${appName}/${p.folder} && bun run setup`,
|
|
315
|
+
"Heads up",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
456
318
|
}
|
|
457
319
|
|
|
458
|
-
|
|
459
|
-
|
|
320
|
+
// Safety net: whatever happened above, each part ends up as its own fresh
|
|
321
|
+
// git repo (the wizard usually did this already).
|
|
322
|
+
if (!existsSync(resolve(dest, ".git"))) {
|
|
323
|
+
Bun.spawnSync(["git", "init", "-b", "main"], { cwd: dest, stdout: "pipe", stderr: "pipe" });
|
|
324
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: dest, stdout: "pipe", stderr: "pipe" });
|
|
325
|
+
Bun.spawnSync(["git", "commit", "-m", "init from template"], { cwd: dest, stdout: "pipe", stderr: "pipe" });
|
|
326
|
+
}
|
|
460
327
|
|
|
461
|
-
|
|
462
|
-
|
|
328
|
+
ready.push(p);
|
|
329
|
+
}
|
|
463
330
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
flags.owner,
|
|
468
|
-
Bun.env.MACOSTACK_GITHUB_OWNER ?? DEFAULT_OWNER,
|
|
469
|
-
);
|
|
470
|
-
const ref = getStringFlag(flags.ref, Bun.env.MACOSTACK_REF ?? DEFAULT_REF);
|
|
471
|
-
const githubToken = requireGithubToken();
|
|
472
|
-
|
|
473
|
-
const config: ScaffoldConfig = {
|
|
474
|
-
owner,
|
|
475
|
-
ref,
|
|
476
|
-
token: githubToken,
|
|
477
|
-
targetRoot: resolve(process.cwd(), targetName),
|
|
478
|
-
targetName,
|
|
479
|
-
skipInstall: getBooleanFlag(flags["skip-install"]),
|
|
480
|
-
skipSetup: getBooleanFlag(flags["skip-setup"]),
|
|
481
|
-
};
|
|
482
|
-
const templates = buildTemplates(selectedParts);
|
|
483
|
-
|
|
484
|
-
ensureEmptyDirectory(config.targetRoot, "Target root");
|
|
485
|
-
|
|
486
|
-
for (const template of templates) {
|
|
487
|
-
await scaffoldTemplate(config, template);
|
|
488
|
-
}
|
|
331
|
+
//////////////////////////////////////////////////////////////////
|
|
332
|
+
// DONE
|
|
333
|
+
//////////////////////////////////////////////////////////////////
|
|
489
334
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
)
|
|
497
|
-
.join("\n\n");
|
|
498
|
-
|
|
499
|
-
const devSteps = templates
|
|
500
|
-
.map((template) => ` cd ${config.targetName}/${template.directory} && bun dev`)
|
|
501
|
-
.join("\n");
|
|
502
|
-
|
|
503
|
-
outro(
|
|
504
|
-
`Done. Root folder is intentionally not a git repo.\n\n` +
|
|
505
|
-
`Next remotes:\n${remoteSteps}\n\n` +
|
|
506
|
-
`Dev:\n${devSteps}`,
|
|
335
|
+
const steps: string[] = [];
|
|
336
|
+
const server = ready.find((p) => p.id === "server");
|
|
337
|
+
const client = ready.find((p) => p.id === "client");
|
|
338
|
+
if (server) {
|
|
339
|
+
steps.push(
|
|
340
|
+
`Start the backend:\n cd ${appName}/server\n docker compose up -d && bun run db:migrate && bun dev`,
|
|
507
341
|
);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
342
|
+
}
|
|
343
|
+
if (client) {
|
|
344
|
+
steps.push(`Start the web app:\n cd ${appName}/client\n bun dev`);
|
|
345
|
+
}
|
|
346
|
+
steps.push(
|
|
347
|
+
`When you're ready to publish (each part is its own repo):\n` +
|
|
348
|
+
ready
|
|
349
|
+
.map((p) => ` cd ${appName}/${p.folder} && git remote add origin <your-repo> && git push -u origin main`)
|
|
350
|
+
.join("\n"),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
outro(`${appName} is ready 🎉\n\n${steps.join("\n\n")}`);
|