create-macostack 0.1.1 → 0.2.1
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 +269 -413
- package/package.json +1 -1
package/bin/create-macostack.ts
CHANGED
|
@@ -20,52 +20,52 @@ import { tmpdir } from "node:os";
|
|
|
20
20
|
import { join, resolve } from "node:path";
|
|
21
21
|
import { parseArgs } from "node:util";
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
// create-macostack — start a new app from the macostack templates.
|
|
24
|
+
//
|
|
25
|
+
// bun create macostack my-app
|
|
26
|
+
//
|
|
27
|
+
// It asks what your app needs (backend API, web app, or both), downloads each
|
|
28
|
+
// template WITHOUT its git history, installs dependencies, and hands you over
|
|
29
|
+
// to each template's own setup wizard. The orchestrator stays deliberately
|
|
30
|
+
// dumb: it never knows what modules exist — each template owns its questions.
|
|
31
|
+
|
|
32
|
+
//////////////////////////////////////////////////////////////////
|
|
33
|
+
// CONFIG
|
|
34
|
+
//////////////////////////////////////////////////////////////////
|
|
35
|
+
|
|
36
|
+
const DEFAULT_OWNER = Bun.env.MACOSTACK_GITHUB_OWNER ?? "macobits";
|
|
37
|
+
const DEFAULT_REF = Bun.env.MACOSTACK_REF ?? "main";
|
|
38
|
+
|
|
39
|
+
type Part = {
|
|
40
|
+
id: "server" | "client";
|
|
41
|
+
folder: string;
|
|
33
42
|
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
43
|
label: string;
|
|
56
|
-
|
|
44
|
+
hint: string;
|
|
57
45
|
};
|
|
58
46
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
47
|
+
const PARTS: Part[] = [
|
|
48
|
+
{
|
|
49
|
+
id: "server",
|
|
50
|
+
folder: "server",
|
|
51
|
+
repo: Bun.env.MACOSTACK_SERVER_REPO ?? "macostack-server",
|
|
52
|
+
label: "Backend API",
|
|
53
|
+
hint: "Bun + Hono + Postgres — auth included",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "client",
|
|
57
|
+
folder: "client",
|
|
58
|
+
repo: Bun.env.MACOSTACK_CLIENT_REPO ?? "macostack-client",
|
|
59
|
+
label: "Web app",
|
|
60
|
+
hint: "the frontend",
|
|
61
|
+
},
|
|
62
|
+
];
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const DEFAULT_CLIENT_REPO = "macostack-client";
|
|
64
|
+
//////////////////////////////////////////////////////////////////
|
|
65
|
+
// FLAGS
|
|
66
|
+
//////////////////////////////////////////////////////////////////
|
|
67
67
|
|
|
68
|
-
const
|
|
68
|
+
const parsed = parseArgs({
|
|
69
69
|
args: Bun.argv.slice(2),
|
|
70
70
|
allowPositionals: true,
|
|
71
71
|
options: {
|
|
@@ -81,36 +81,35 @@ const parsedArgs = parseArgs({
|
|
|
81
81
|
"skip-setup": { type: "boolean", default: false },
|
|
82
82
|
},
|
|
83
83
|
});
|
|
84
|
+
const flags = parsed.values;
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const HELP_TEXT = `create-macostack
|
|
86
|
+
if (flags.help) {
|
|
87
|
+
console.log(`create-macostack — start a new app from the macostack templates
|
|
88
88
|
|
|
89
89
|
Usage:
|
|
90
|
-
bun create macostack <
|
|
91
|
-
bun run dev <venture-folder> [flags]
|
|
90
|
+
bun create macostack <app-name>
|
|
92
91
|
|
|
93
92
|
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);
|
|
93
|
+
--server / --client scaffold only that part (default: it asks)
|
|
94
|
+
--yes no questions: both parts, template defaults
|
|
95
|
+
--owner <org> GitHub owner (default: ${DEFAULT_OWNER})
|
|
96
|
+
--ref <ref> branch or tag to pull (default: ${DEFAULT_REF})
|
|
97
|
+
--server-repo <name> server template repo
|
|
98
|
+
--client-repo <name> client template repo
|
|
99
|
+
--skip-install don't run bun install
|
|
100
|
+
--skip-setup don't run each template's setup wizard
|
|
101
|
+
|
|
102
|
+
Auth (templates are private):
|
|
103
|
+
export GITHUB_TOKEN=github_pat_… or gh auth login
|
|
104
|
+
(fine-grained token, Contents: Read-only is enough)
|
|
105
|
+
`);
|
|
111
106
|
process.exit(0);
|
|
112
107
|
}
|
|
113
108
|
|
|
109
|
+
//////////////////////////////////////////////////////////////////
|
|
110
|
+
// HELPERS
|
|
111
|
+
//////////////////////////////////////////////////////////////////
|
|
112
|
+
|
|
114
113
|
const bail = (message: string): never => {
|
|
115
114
|
cancel(message);
|
|
116
115
|
process.exit(1);
|
|
@@ -118,393 +117,250 @@ const bail = (message: string): never => {
|
|
|
118
117
|
|
|
119
118
|
const answered = <T>(value: T | symbol): T => {
|
|
120
119
|
if (isCancel(value)) {
|
|
121
|
-
cancel("
|
|
120
|
+
cancel("No problem — nothing was created.");
|
|
122
121
|
process.exit(0);
|
|
123
122
|
}
|
|
124
|
-
|
|
125
123
|
return value as T;
|
|
126
124
|
};
|
|
127
125
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return fallback;
|
|
126
|
+
const githubToken = (): string | null => {
|
|
127
|
+
const fromEnv = Bun.env.GITHUB_TOKEN ?? Bun.env.GH_TOKEN;
|
|
128
|
+
if (fromEnv) return fromEnv;
|
|
129
|
+
const gh = Bun.spawnSync(["gh", "auth", "token"], { stdout: "pipe", stderr: "pipe" });
|
|
130
|
+
const token = gh.exitCode === 0 ? gh.stdout.toString().trim() : "";
|
|
131
|
+
return token || null;
|
|
137
132
|
};
|
|
138
133
|
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
typeof value === "object" && value !== null;
|
|
134
|
+
const isEmptyDir = (dir: string) =>
|
|
135
|
+
!existsSync(dir) ||
|
|
136
|
+
readdirSync(dir).filter((e) => e !== ".DS_Store").length === 0;
|
|
143
137
|
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
//////////////////////////////////////////////////////////////////
|
|
139
|
+
// WIZARD
|
|
140
|
+
//////////////////////////////////////////////////////////////////
|
|
146
141
|
|
|
147
|
-
|
|
148
|
-
value === STACK_PART.SERVER || value === STACK_PART.CLIENT;
|
|
142
|
+
intro("create-macostack");
|
|
149
143
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
144
|
+
// 1. App name (positional arg or prompt).
|
|
145
|
+
const positional = parsed.positionals[0];
|
|
146
|
+
const validName = (v: string) => /^[a-z0-9][a-z0-9-]*$/.test(v);
|
|
147
|
+
let appName: string;
|
|
148
|
+
if (positional) {
|
|
149
|
+
if (!validName(positional)) {
|
|
150
|
+
bail("App names use lowercase letters, digits and dashes — e.g. soccer-app");
|
|
154
151
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
Object.entries(scriptsValue).filter(
|
|
168
|
-
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
169
|
-
),
|
|
152
|
+
appName = positional;
|
|
153
|
+
} else if (flags.yes) {
|
|
154
|
+
bail("Tell me the app name: bun create macostack my-app --yes");
|
|
155
|
+
throw ""; // unreachable
|
|
156
|
+
} else {
|
|
157
|
+
appName = answered(
|
|
158
|
+
await text({
|
|
159
|
+
message: "What should we call your app?",
|
|
160
|
+
placeholder: "soccer-app",
|
|
161
|
+
validate: (v) =>
|
|
162
|
+
validName(v ?? "") ? undefined : "lowercase letters, digits and dashes — e.g. soccer-app",
|
|
163
|
+
}),
|
|
170
164
|
);
|
|
165
|
+
}
|
|
171
166
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const result = Bun.spawnSync(["gh", "auth", "token"], {
|
|
177
|
-
stdout: "pipe",
|
|
178
|
-
stderr: "pipe",
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
if (result.exitCode !== 0) {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
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
|
-
};
|
|
197
|
-
|
|
198
|
-
const requireGithubToken = (): string => {
|
|
199
|
-
const token = getGithubToken();
|
|
200
|
-
if (typeof token === "string" && token.length > 0) {
|
|
201
|
-
return token;
|
|
202
|
-
}
|
|
167
|
+
const targetRoot = resolve(process.cwd(), appName);
|
|
168
|
+
if (!isEmptyDir(targetRoot)) {
|
|
169
|
+
bail(`The folder ./${appName} already exists and isn't empty — pick another name or clear it first.`);
|
|
170
|
+
}
|
|
203
171
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
172
|
+
// 2. Which parts?
|
|
173
|
+
let selected: Part[];
|
|
174
|
+
if (flags.server || flags.client) {
|
|
175
|
+
selected = PARTS.filter((p) => flags[p.id]);
|
|
176
|
+
} else if (flags.yes) {
|
|
177
|
+
selected = PARTS;
|
|
178
|
+
} else {
|
|
179
|
+
const picked = answered(
|
|
180
|
+
await multiselect({
|
|
181
|
+
message: `What does ${appName} need? (space to pick, enter to continue)`,
|
|
182
|
+
options: PARTS.map((p) => ({ value: p.id, label: p.label, hint: p.hint })),
|
|
183
|
+
initialValues: PARTS.map((p) => p.id),
|
|
184
|
+
required: true,
|
|
185
|
+
}),
|
|
207
186
|
);
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const ensureEmptyDirectory = (directory: string, label: string) => {
|
|
211
|
-
if (!existsSync(directory)) {
|
|
212
|
-
mkdirSync(directory, { recursive: true });
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
187
|
+
selected = PARTS.filter((p) => picked.includes(p.id));
|
|
188
|
+
}
|
|
215
189
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
190
|
+
// 3. GitHub access — checked before anything is created.
|
|
191
|
+
const owner = flags.owner ?? DEFAULT_OWNER;
|
|
192
|
+
const ref = flags.ref ?? DEFAULT_REF;
|
|
193
|
+
const token = githubToken();
|
|
194
|
+
if (!token) {
|
|
195
|
+
bail(
|
|
196
|
+
"I need GitHub access to download the templates (they're private).\n" +
|
|
197
|
+
" Easiest: gh auth login\n" +
|
|
198
|
+
" Or: export GITHUB_TOKEN=github_pat_… (Contents: Read-only)",
|
|
199
|
+
);
|
|
200
|
+
throw ""; // unreachable
|
|
201
|
+
}
|
|
202
|
+
for (const p of selected) {
|
|
203
|
+
p.repo = (p.id === "server" ? flags["server-repo"] : flags["client-repo"]) ?? p.repo;
|
|
204
|
+
}
|
|
221
205
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
stderr: "inherit",
|
|
206
|
+
// Preflight every selected template so a typo'd repo or missing access fails
|
|
207
|
+
// BEFORE any folders exist.
|
|
208
|
+
const gh = (path: string) =>
|
|
209
|
+
fetch(`https://api.github.com${path}`, {
|
|
210
|
+
headers: {
|
|
211
|
+
Accept: "application/vnd.github+json",
|
|
212
|
+
Authorization: `Bearer ${token}`,
|
|
213
|
+
"User-Agent": "create-macostack",
|
|
214
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
215
|
+
},
|
|
233
216
|
});
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
217
|
+
for (const p of selected) {
|
|
218
|
+
const res = await gh(`/repos/${owner}/${p.repo}`);
|
|
219
|
+
if (!res.ok) {
|
|
220
|
+
bail(
|
|
221
|
+
`Can't reach ${owner}/${p.repo} (HTTP ${res.status}).\n` +
|
|
222
|
+
" Does the repo exist, and does your token have read access to it?",
|
|
223
|
+
);
|
|
237
224
|
}
|
|
225
|
+
}
|
|
238
226
|
|
|
239
|
-
|
|
240
|
-
|
|
227
|
+
//////////////////////////////////////////////////////////////////
|
|
228
|
+
// SCAFFOLD
|
|
229
|
+
//////////////////////////////////////////////////////////////////
|
|
241
230
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
const archivePath = join(temporaryDirectory, `${template.part}.tar.gz`);
|
|
249
|
-
const tarballUrl = `https://api.github.com/repos/${config.owner}/${template.repo}/tarball/${config.ref}`;
|
|
231
|
+
mkdirSync(targetRoot, { recursive: true });
|
|
232
|
+
const ready: Part[] = [];
|
|
233
|
+
|
|
234
|
+
for (const p of selected) {
|
|
235
|
+
const dest = resolve(targetRoot, p.folder);
|
|
236
|
+
const s = spinner();
|
|
250
237
|
|
|
238
|
+
// Download the tarball — no git history comes with it, so each part starts
|
|
239
|
+
// life fully detached from the template.
|
|
240
|
+
s.start(`${p.label} — downloading ${owner}/${p.repo}@${ref}`);
|
|
241
|
+
const tmp = mkdtempSync(join(tmpdir(), "create-macostack-"));
|
|
251
242
|
try {
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
);
|
|
243
|
+
const res = await gh(`/repos/${owner}/${p.repo}/tarball/${ref}`);
|
|
244
|
+
if (!res.ok) {
|
|
245
|
+
s.stop(`${p.label} — download failed`);
|
|
246
|
+
bail(`GitHub refused the download (HTTP ${res.status}). Try again, or check --ref ${ref}.`);
|
|
247
|
+
}
|
|
248
|
+
const archive = join(tmp, "template.tar.gz");
|
|
249
|
+
await Bun.write(archive, await res.arrayBuffer());
|
|
250
|
+
mkdirSync(dest, { recursive: true });
|
|
251
|
+
const tar = Bun.spawnSync(
|
|
252
|
+
["tar", "-xzf", archive, "-C", dest, "--strip-components=1"],
|
|
253
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
254
|
+
);
|
|
255
|
+
if (tar.exitCode !== 0) {
|
|
256
|
+
s.stop(`${p.label} — extract failed`);
|
|
257
|
+
bail(`Couldn't unpack the template: ${tar.stderr.toString().slice(0, 200)}`);
|
|
267
258
|
}
|
|
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
259
|
} finally {
|
|
284
|
-
rmSync(
|
|
285
|
-
}
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
const runInstall = (directory: string, template: TemplateConfig) => {
|
|
289
|
-
runCommand({
|
|
290
|
-
command: ["bun", "install"],
|
|
291
|
-
cwd: directory,
|
|
292
|
-
label: `bun install in ${template.part}`,
|
|
293
|
-
});
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
const runSetup = async (directory: string, template: TemplateConfig) => {
|
|
297
|
-
const packageJson = await readPackageJson(directory);
|
|
298
|
-
if (!packageJson?.scripts?.setup) {
|
|
299
|
-
note(`No setup script found in ${template.directory}; skipping delegation.`, template.part);
|
|
300
|
-
return;
|
|
260
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
301
261
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
label
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
runCommand({
|
|
316
|
-
command: ["git", "init", "-b", "main"],
|
|
317
|
-
cwd: directory,
|
|
318
|
-
label: `git init in ${template.part}`,
|
|
319
|
-
});
|
|
320
|
-
runCommand({
|
|
321
|
-
command: ["git", "add", "-A"],
|
|
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",
|
|
262
|
+
s.stop(`${p.label} — downloaded into ${appName}/${p.folder}`);
|
|
263
|
+
|
|
264
|
+
if (!flags["skip-install"]) {
|
|
265
|
+
const s2 = spinner();
|
|
266
|
+
s2.start(`${p.label} — installing dependencies`);
|
|
267
|
+
const code = Bun.spawnSync(["bun", "install"], { cwd: dest, stdout: "pipe", stderr: "pipe" }).exitCode;
|
|
268
|
+
s2.stop(
|
|
269
|
+
code === 0
|
|
270
|
+
? `${p.label} — dependencies installed`
|
|
271
|
+
: `${p.label} — bun install had issues (run it again inside ${appName}/${p.folder})`,
|
|
338
272
|
);
|
|
339
273
|
}
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
const getSelectedPartsFromFlags = () => {
|
|
343
|
-
const selected: StackPart[] = [];
|
|
344
|
-
|
|
345
|
-
if (getBooleanFlag(flags.server)) {
|
|
346
|
-
selected.push(STACK_PART.SERVER);
|
|
347
|
-
}
|
|
348
274
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
275
|
+
// Configure the template. It OWNS its questions — we fetch them as data
|
|
276
|
+
// (`setup --questions`), ask them in THIS wizard (nested interactive
|
|
277
|
+
// prompts don't get keyboard control under `bun create`), and apply the
|
|
278
|
+
// answers non-interactively (`setup --yes`).
|
|
279
|
+
const pkg = existsSync(resolve(dest, "package.json"))
|
|
280
|
+
? await Bun.file(resolve(dest, "package.json")).json()
|
|
281
|
+
: null;
|
|
282
|
+
if (!flags["skip-setup"] && pkg?.scripts?.setup) {
|
|
283
|
+
let questions: { modules?: { options: string[]; initial: string[]; exclusive?: string[][] } } | null = null;
|
|
284
|
+
if (!flags.yes) {
|
|
285
|
+
const q = Bun.spawnSync(["bun", "run", "setup", "--questions"], {
|
|
286
|
+
cwd: dest,
|
|
287
|
+
stdout: "pipe",
|
|
288
|
+
stderr: "pipe",
|
|
289
|
+
});
|
|
290
|
+
if (q.exitCode === 0) {
|
|
291
|
+
try {
|
|
292
|
+
questions = JSON.parse(q.stdout.toString().trim().split("\n").at(-1) ?? "");
|
|
293
|
+
} catch {}
|
|
294
|
+
}
|
|
361
295
|
}
|
|
362
296
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const resolveSelectedParts = async () => {
|
|
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
|
-
}
|
|
393
|
-
|
|
394
|
-
const selected = answered(
|
|
395
|
-
await multiselect({
|
|
396
|
-
message: "¿Qué incluye este venture?",
|
|
397
|
-
options: [
|
|
398
|
-
{ value: STACK_PART.SERVER, label: "server" },
|
|
399
|
-
{ value: STACK_PART.CLIENT, label: "client" },
|
|
400
|
-
],
|
|
401
|
-
initialValues: [STACK_PART.SERVER, STACK_PART.CLIENT],
|
|
402
|
-
required: true,
|
|
403
|
-
}),
|
|
404
|
-
);
|
|
405
|
-
|
|
406
|
-
const validSelected = selected.filter(isStackPart);
|
|
407
|
-
if (validSelected.length === 0) {
|
|
408
|
-
bail("Select at least one half.");
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return validSelected;
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
const buildTemplates = (selectedParts: StackPart[]) => {
|
|
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,
|
|
422
|
-
);
|
|
423
|
-
|
|
424
|
-
const templates = {
|
|
425
|
-
[STACK_PART.SERVER]: {
|
|
426
|
-
part: STACK_PART.SERVER,
|
|
427
|
-
directory: "server",
|
|
428
|
-
repo: serverRepo,
|
|
429
|
-
},
|
|
430
|
-
[STACK_PART.CLIENT]: {
|
|
431
|
-
part: STACK_PART.CLIENT,
|
|
432
|
-
directory: "client",
|
|
433
|
-
repo: clientRepo,
|
|
434
|
-
},
|
|
435
|
-
} satisfies Record<StackPart, TemplateConfig>;
|
|
436
|
-
|
|
437
|
-
return [STACK_PART.SERVER, STACK_PART.CLIENT]
|
|
438
|
-
.filter((part) => selectedParts.includes(part))
|
|
439
|
-
.map((part) => templates[part]);
|
|
440
|
-
};
|
|
441
|
-
|
|
442
|
-
const scaffoldTemplate = async (config: ScaffoldConfig, template: TemplateConfig) => {
|
|
443
|
-
const destination = resolve(config.targetRoot, template.directory);
|
|
444
|
-
const s = spinner();
|
|
445
|
-
|
|
446
|
-
s.start(`Downloading ${config.owner}/${template.repo}@${config.ref} → ${template.directory}`);
|
|
447
|
-
await downloadTemplate({ config, template, destination });
|
|
448
|
-
s.stop(`${template.directory} downloaded`);
|
|
297
|
+
const setupArgs = ["run", "setup", "--yes", "--name", appName];
|
|
298
|
+
|
|
299
|
+
if (questions?.modules) {
|
|
300
|
+
let chosen: string[];
|
|
301
|
+
for (;;) {
|
|
302
|
+
chosen = answered(
|
|
303
|
+
await multiselect({
|
|
304
|
+
message: `${p.label} — which modules should start ON? (everything stays in the code; off = dormant, ready when you need it)`,
|
|
305
|
+
options: questions.modules.options.map((m) => ({ value: m, label: m })),
|
|
306
|
+
initialValues: questions.modules.initial,
|
|
307
|
+
required: false,
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
const clash = (questions.modules.exclusive ?? []).find((pair) =>
|
|
311
|
+
pair.every((m) => chosen.includes(m)),
|
|
312
|
+
);
|
|
313
|
+
if (!clash) break;
|
|
314
|
+
note(`${clash.join(" and ")} can't both be on — keep one of the two.`, "One thing");
|
|
315
|
+
}
|
|
316
|
+
setupArgs.push("--modules", chosen.join(","));
|
|
317
|
+
}
|
|
449
318
|
|
|
450
|
-
|
|
451
|
-
|
|
319
|
+
const s3 = spinner();
|
|
320
|
+
s3.start(`${p.label} — applying your setup (modules, fresh secrets, git)`);
|
|
321
|
+
const setup = Bun.spawnSync(["bun", ...setupArgs], { cwd: dest, stdout: "pipe", stderr: "pipe" });
|
|
322
|
+
if (setup.exitCode === 0) {
|
|
323
|
+
s3.stop(`${p.label} — configured`);
|
|
324
|
+
} else {
|
|
325
|
+
s3.stop(`${p.label} — setup didn't finish`);
|
|
326
|
+
note(
|
|
327
|
+
`Everything downloaded fine — just finish it yourself:\n cd ${appName}/${p.folder} && bun run setup`,
|
|
328
|
+
"Heads up",
|
|
329
|
+
);
|
|
330
|
+
}
|
|
452
331
|
}
|
|
453
332
|
|
|
454
|
-
|
|
455
|
-
|
|
333
|
+
// Safety net: whatever happened above, each part ends up as its own fresh
|
|
334
|
+
// git repo (the wizard usually did this already).
|
|
335
|
+
if (!existsSync(resolve(dest, ".git"))) {
|
|
336
|
+
Bun.spawnSync(["git", "init", "-b", "main"], { cwd: dest, stdout: "pipe", stderr: "pipe" });
|
|
337
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: dest, stdout: "pipe", stderr: "pipe" });
|
|
338
|
+
Bun.spawnSync(["git", "commit", "-m", "init from template"], { cwd: dest, stdout: "pipe", stderr: "pipe" });
|
|
456
339
|
}
|
|
457
340
|
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
const main = async () => {
|
|
462
|
-
intro("create-macostack");
|
|
341
|
+
ready.push(p);
|
|
342
|
+
}
|
|
463
343
|
|
|
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
|
-
}
|
|
344
|
+
//////////////////////////////////////////////////////////////////
|
|
345
|
+
// DONE
|
|
346
|
+
//////////////////////////////////////////////////////////////////
|
|
489
347
|
|
|
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}`,
|
|
348
|
+
const steps: string[] = [];
|
|
349
|
+
const server = ready.find((p) => p.id === "server");
|
|
350
|
+
const client = ready.find((p) => p.id === "client");
|
|
351
|
+
if (server) {
|
|
352
|
+
steps.push(
|
|
353
|
+
`Start the backend:\n cd ${appName}/server\n docker compose up -d && bun run db:migrate && bun dev`,
|
|
507
354
|
);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
355
|
+
}
|
|
356
|
+
if (client) {
|
|
357
|
+
steps.push(`Start the web app:\n cd ${appName}/client\n bun dev`);
|
|
358
|
+
}
|
|
359
|
+
steps.push(
|
|
360
|
+
`When you're ready to publish (each part is its own repo):\n` +
|
|
361
|
+
ready
|
|
362
|
+
.map((p) => ` cd ${appName}/${p.folder} && git remote add origin <your-repo> && git push -u origin main`)
|
|
363
|
+
.join("\n"),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
outro(`${appName} is ready 🎉\n\n${steps.join("\n\n")}`);
|