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.
@@ -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
- const STACK_PART = {
24
- SERVER: "server",
25
- CLIENT: "client",
26
- } as const;
27
-
28
- type StackPart = (typeof STACK_PART)[keyof typeof STACK_PART];
29
-
30
- type TemplateConfig = {
31
- part: StackPart;
32
- directory: string;
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
- allowFailure?: boolean;
48
+ hint: string;
57
49
  };
58
50
 
59
- type PackageJson = {
60
- scripts?: Record<string, string>;
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
- const DEFAULT_OWNER = "macobits";
64
- const DEFAULT_REF = "main";
65
- const DEFAULT_SERVER_REPO = "macostack-server";
66
- const DEFAULT_CLIENT_REPO = "macostack-client";
68
+ //////////////////////////////////////////////////////////////////
69
+ // FLAGS
70
+ //////////////////////////////////////////////////////////////////
67
71
 
68
- const parsedArgs = parseArgs({
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
- const flags = parsedArgs.values;
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 <venture-folder>
91
- bun run dev <venture-folder> [flags]
94
+ bun create macostack <app-name>
92
95
 
93
96
  Flags:
94
- --server scaffold only server
95
- --client scaffold only client
96
- --owner <owner> GitHub owner/org (default: macobits)
97
- --ref <ref> Git ref/tag/branch (default: main)
98
- --server-repo <repo> server template repo (default: macostack-server)
99
- --client-repo <repo> client template repo (default: macostack-client)
100
- --skip-install do not run bun install in generated halves
101
- --skip-setup do not run bun run setup in generated halves
102
- --yes skip create-macostack prompts; selects server + client
103
- --help show this help
104
-
105
- Auth:
106
- Set GITHUB_TOKEN or run gh auth login for private template tarballs.
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("Scaffold aborted — nothing else will be touched.");
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
- const getStringFlag = (
129
- value: string | boolean | undefined,
130
- fallback: string,
131
- ) => {
132
- if (typeof value === "string" && value.length > 0) {
133
- return value;
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 requireGithubToken = (): string => {
199
- const token = getGithubToken();
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
- if (result.exitCode === 0 || allowFailure) {
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 downloadTemplate = async ({
243
- config,
244
- template,
245
- destination,
246
- }: DownloadTemplateProps) => {
247
- const temporaryDirectory = mkdtempSync(join(tmpdir(), "create-macostack-"));
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 runInstall = (directory: string, template: TemplateConfig) => {
289
- runCommand({
290
- command: ["bun", "install"],
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
- 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;
301
- }
165
+ //////////////////////////////////////////////////////////////////
166
+ // WIZARD
167
+ //////////////////////////////////////////////////////////////////
302
168
 
303
- runCommand({
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
- 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",
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
- const getSelectedPartsFromFlags = () => {
343
- const selected: StackPart[] = [];
344
-
345
- if (getBooleanFlag(flags.server)) {
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: "Venture folder",
373
- placeholder: "mi-tienda",
374
- validate: (input) =>
375
- isValidDirectoryName(input ?? "")
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
- return value;
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
- }
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
- const selected = answered(
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: "¿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],
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
- 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,
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
- const templates = {
425
- [STACK_PART.SERVER]: {
426
- part: STACK_PART.SERVER,
427
- directory: "server",
428
- repo: serverRepo,
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
- [STACK_PART.CLIENT]: {
431
- part: STACK_PART.CLIENT,
432
- directory: "client",
433
- repo: clientRepo,
434
- },
435
- } satisfies Record<StackPart, TemplateConfig>;
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
- return [STACK_PART.SERVER, STACK_PART.CLIENT]
438
- .filter((part) => selectedParts.includes(part))
439
- .map((part) => templates[part]);
440
- };
254
+ //////////////////////////////////////////////////////////////////
255
+ // SCAFFOLD
256
+ //////////////////////////////////////////////////////////////////
441
257
 
442
- const scaffoldTemplate = async (config: ScaffoldConfig, template: TemplateConfig) => {
443
- const destination = resolve(config.targetRoot, template.directory);
444
- const s = spinner();
258
+ mkdirSync(targetRoot, { recursive: true });
259
+ const ready: Part[] = [];
445
260
 
446
- s.start(`Downloading ${config.owner}/${template.repo}@${config.ref} ${template.directory}`);
447
- await downloadTemplate({ config, template, destination });
448
- s.stop(`${template.directory} downloaded`);
261
+ for (const p of selected) {
262
+ const dest = resolve(targetRoot, p.folder);
263
+ const s = spinner();
449
264
 
450
- if (!config.skipInstall) {
451
- runInstall(destination, template);
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
- if (!config.skipSetup) {
455
- await runSetup(destination, template);
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
- ensureGitRepository(destination, template);
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
- const main = async () => {
462
- intro("create-macostack");
328
+ ready.push(p);
329
+ }
463
330
 
464
- const targetName = await resolveTargetName();
465
- const selectedParts = await resolveSelectedParts();
466
- const owner = getStringFlag(
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
- const remoteSteps = templates
491
- .map(
492
- (template) =>
493
- ` cd ${config.targetName}/${template.directory}\n` +
494
- ` git remote add origin <${template.part}-repo-url>\n` +
495
- " git push -u origin main",
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
- await main();
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")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-macostack",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Private macostack scaffold orchestrator for detached server/client repos.",
5
5
  "type": "module",
6
6
  "bin": {