create-macostack 0.1.0 → 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,390 +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,
145
+ stdin: ttyFd ?? "inherit",
230
146
  stdout: "inherit",
231
147
  stderr: "inherit",
232
148
  });
233
-
234
- if (result.exitCode === 0 || allowFailure) {
235
- return result.exitCode;
236
- }
237
-
238
- bail(`${label} failed with exit code ${result.exitCode}.`);
149
+ if (ttyFd !== null) closeSync(ttyFd);
150
+ return result.exitCode;
239
151
  };
240
152
 
241
- const downloadTemplate = async ({
242
- config,
243
- template,
244
- destination,
245
- }: DownloadTemplateProps) => {
246
- const temporaryDirectory = mkdtempSync(join(tmpdir(), "create-macostack-"));
247
- const archivePath = join(temporaryDirectory, `${template.part}.tar.gz`);
248
- const tarballUrl = `https://api.github.com/repos/${config.owner}/${template.repo}/tarball/${config.ref}`;
249
-
250
- try {
251
- const response = await fetch(tarballUrl, {
252
- headers: {
253
- Accept: "application/vnd.github+json",
254
- Authorization: `Bearer ${config.token}`,
255
- "User-Agent": "create-macostack",
256
- "X-GitHub-Api-Version": "2022-11-28",
257
- },
258
- });
259
-
260
- if (!response.ok) {
261
- const details = await response.text();
262
- bail(
263
- `GitHub refused ${config.owner}/${template.repo}@${config.ref} (${response.status}). ` +
264
- `Check GITHUB_TOKEN permissions. ${details.slice(0, 240)}`,
265
- );
266
- }
267
-
268
- await Bun.write(archivePath, await response.arrayBuffer());
269
- ensureEmptyDirectory(destination, template.directory);
270
- runCommand({
271
- command: [
272
- "tar",
273
- "-xzf",
274
- archivePath,
275
- "-C",
276
- destination,
277
- "--strip-components=1",
278
- ],
279
- cwd: destination,
280
- label: `Extract ${template.part}`,
281
- });
282
- } finally {
283
- rmSync(temporaryDirectory, { recursive: true, force: true });
284
- }
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;
285
159
  };
286
160
 
287
- const runInstall = (directory: string, template: TemplateConfig) => {
288
- runCommand({
289
- command: ["bun", "install"],
290
- cwd: directory,
291
- label: `bun install in ${template.part}`,
292
- });
293
- };
161
+ const isEmptyDir = (dir: string) =>
162
+ !existsSync(dir) ||
163
+ readdirSync(dir).filter((e) => e !== ".DS_Store").length === 0;
294
164
 
295
- const runSetup = async (directory: string, template: TemplateConfig) => {
296
- const packageJson = await readPackageJson(directory);
297
- if (!packageJson?.scripts?.setup) {
298
- note(`No setup script found in ${template.directory}; skipping delegation.`, template.part);
299
- return;
300
- }
165
+ //////////////////////////////////////////////////////////////////
166
+ // WIZARD
167
+ //////////////////////////////////////////////////////////////////
301
168
 
302
- runCommand({
303
- command: ["bun", "run", "setup"],
304
- cwd: directory,
305
- label: `setup in ${template.part}`,
306
- });
307
- };
308
-
309
- const ensureGitRepository = (directory: string, template: TemplateConfig) => {
310
- if (existsSync(resolve(directory, ".git"))) {
311
- return;
312
- }
169
+ intro("create-macostack");
313
170
 
314
- runCommand({
315
- command: ["git", "init", "-b", "main"],
316
- cwd: directory,
317
- label: `git init in ${template.part}`,
318
- });
319
- runCommand({
320
- command: ["git", "add", "-A"],
321
- cwd: directory,
322
- label: `git add in ${template.part}`,
323
- });
324
-
325
- const commitExitCode = runCommand({
326
- command: ["git", "commit", "-m", "init from template"],
327
- cwd: directory,
328
- label: `git commit in ${template.part}`,
329
- allowFailure: true,
330
- });
331
-
332
- if (commitExitCode !== 0) {
333
- note(
334
- `Fresh ${template.part} repo created, but initial commit failed. ` +
335
- `Run: cd ${template.directory} && git add -A && git commit -m "init"`,
336
- "Git",
337
- );
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");
338
178
  }
339
- };
340
-
341
- const getSelectedPartsFromFlags = () => {
342
- const selected: StackPart[] = [];
343
-
344
- if (getBooleanFlag(flags.server)) {
345
- selected.push(STACK_PART.SERVER);
346
- }
347
-
348
- if (getBooleanFlag(flags.client)) {
349
- selected.push(STACK_PART.CLIENT);
350
- }
351
-
352
- return selected;
353
- };
354
-
355
- const resolveTargetName = async () => {
356
- const positionalTarget = parsedArgs.positionals[0];
357
- if (positionalTarget) {
358
- if (!isValidDirectoryName(positionalTarget)) {
359
- bail("Project folder must use lowercase letters, digits and dashes only.");
360
- }
361
-
362
- return positionalTarget;
363
- }
364
-
365
- if (getBooleanFlag(flags.yes)) {
366
- bail("Missing project folder. Usage: bun create macostack my-venture");
367
- }
368
-
369
- 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(
370
185
  await text({
371
- message: "Venture folder",
372
- placeholder: "mi-tienda",
373
- validate: (input) =>
374
- isValidDirectoryName(input ?? "")
375
- ? undefined
376
- : "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",
377
190
  }),
378
191
  );
192
+ }
379
193
 
380
- return value;
381
- };
382
-
383
- const resolveSelectedParts = async () => {
384
- const selectedFromFlags = getSelectedPartsFromFlags();
385
- if (selectedFromFlags.length > 0) {
386
- return selectedFromFlags;
387
- }
388
-
389
- if (getBooleanFlag(flags.yes)) {
390
- return [STACK_PART.SERVER, STACK_PART.CLIENT];
391
- }
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
+ }
392
198
 
393
- 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(
394
207
  await multiselect({
395
- message: "¿Qué incluye este venture?",
396
- options: [
397
- { value: STACK_PART.SERVER, label: "server" },
398
- { value: STACK_PART.CLIENT, label: "client" },
399
- ],
400
- 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),
401
211
  required: true,
402
212
  }),
403
213
  );
214
+ selected = PARTS.filter((p) => picked.includes(p.id));
215
+ }
404
216
 
405
- const validSelected = selected.filter(isStackPart);
406
- if (validSelected.length === 0) {
407
- bail("Select at least one half.");
408
- }
409
-
410
- return validSelected;
411
- };
412
-
413
- const buildTemplates = (selectedParts: StackPart[]) => {
414
- const serverRepo = getStringFlag(
415
- flags["server-repo"],
416
- Bun.env.MACOSTACK_SERVER_REPO ?? DEFAULT_SERVER_REPO,
417
- );
418
- const clientRepo = getStringFlag(
419
- flags["client-repo"],
420
- 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)",
421
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
+ }
422
232
 
423
- const templates = {
424
- [STACK_PART.SERVER]: {
425
- part: STACK_PART.SERVER,
426
- directory: "server",
427
- 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",
428
242
  },
429
- [STACK_PART.CLIENT]: {
430
- part: STACK_PART.CLIENT,
431
- directory: "client",
432
- repo: clientRepo,
433
- },
434
- } 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
+ }
435
253
 
436
- return selectedParts.map((part) => templates[part]);
437
- };
254
+ //////////////////////////////////////////////////////////////////
255
+ // SCAFFOLD
256
+ //////////////////////////////////////////////////////////////////
438
257
 
439
- const scaffoldTemplate = async (config: ScaffoldConfig, template: TemplateConfig) => {
440
- const destination = resolve(config.targetRoot, template.directory);
441
- const s = spinner();
258
+ mkdirSync(targetRoot, { recursive: true });
259
+ const ready: Part[] = [];
442
260
 
443
- s.start(`Downloading ${config.owner}/${template.repo}@${config.ref} ${template.directory}`);
444
- await downloadTemplate({ config, template, destination });
445
- s.stop(`${template.directory} downloaded`);
261
+ for (const p of selected) {
262
+ const dest = resolve(targetRoot, p.folder);
263
+ const s = spinner();
446
264
 
447
- if (!config.skipInstall) {
448
- 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
+ );
449
300
  }
450
301
 
451
- if (!config.skipSetup) {
452
- 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
+ }
453
318
  }
454
319
 
455
- ensureGitRepository(destination, template);
456
- };
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
+ }
457
327
 
458
- const main = async () => {
459
- intro("create-macostack");
328
+ ready.push(p);
329
+ }
460
330
 
461
- const targetName = await resolveTargetName();
462
- const selectedParts = await resolveSelectedParts();
463
- const owner = getStringFlag(
464
- flags.owner,
465
- Bun.env.MACOSTACK_GITHUB_OWNER ?? DEFAULT_OWNER,
466
- );
467
- const ref = getStringFlag(flags.ref, Bun.env.MACOSTACK_REF ?? DEFAULT_REF);
468
- const githubToken = requireGithubToken();
469
-
470
- const config: ScaffoldConfig = {
471
- owner,
472
- ref,
473
- token: githubToken,
474
- targetRoot: resolve(process.cwd(), targetName),
475
- targetName,
476
- skipInstall: getBooleanFlag(flags["skip-install"]),
477
- skipSetup: getBooleanFlag(flags["skip-setup"]),
478
- };
479
- const templates = buildTemplates(selectedParts);
480
-
481
- ensureEmptyDirectory(config.targetRoot, "Target root");
482
-
483
- for (const template of templates) {
484
- await scaffoldTemplate(config, template);
485
- }
331
+ //////////////////////////////////////////////////////////////////
332
+ // DONE
333
+ //////////////////////////////////////////////////////////////////
486
334
 
487
- const remoteSteps = templates
488
- .map(
489
- (template) =>
490
- ` cd ${config.targetName}/${template.directory}\n` +
491
- ` git remote add origin <${template.part}-repo-url>\n` +
492
- " git push -u origin main",
493
- )
494
- .join("\n\n");
495
-
496
- const devSteps = templates
497
- .map((template) => ` cd ${config.targetName}/${template.directory} && bun dev`)
498
- .join("\n");
499
-
500
- outro(
501
- `Done. Root folder is intentionally not a git repo.\n\n` +
502
- `Next remotes:\n${remoteSteps}\n\n` +
503
- `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`,
504
341
  );
505
- };
506
-
507
- 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.0",
3
+ "version": "0.2.0",
4
4
  "description": "Private macostack scaffold orchestrator for detached server/client repos.",
5
5
  "type": "module",
6
6
  "bin": {