partweave 0.2.0 → 0.3.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,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/commands/create.ts
4
+ import { spawnSync as spawnSync3 } from "child_process";
4
5
  import { existsSync as existsSync5, readdirSync as readdirSync3 } from "fs";
5
6
  import { basename, resolve as resolve3 } from "path";
6
- import { intro, log, note, outro, spinner } from "@clack/prompts";
7
+ import { confirm as confirm3, intro, isCancel as isCancel3, log as log2, note, outro, spinner as spinner2 } from "@clack/prompts";
7
8
  import pc2 from "picocolors";
8
9
 
9
10
  // src/compose.ts
@@ -42,6 +43,13 @@ function parseDep(dep) {
42
43
  }
43
44
  return { name: dep, version: "latest" };
44
45
  }
46
+ function normalizeWorkspaceDeps(deps, workspaceRange) {
47
+ return deps.map((dep) => {
48
+ const { name, version } = parseDep(dep);
49
+ if (version.startsWith("workspace:")) return `${name}@${workspaceRange}`;
50
+ return dep;
51
+ });
52
+ }
45
53
  function mergePackageJsonDeps(pkgJson, deps, field = "dependencies") {
46
54
  if (deps.length === 0) return pkgJson;
47
55
  const pkg = JSON.parse(pkgJson);
@@ -180,7 +188,8 @@ function jsPmProfile(name) {
180
188
  runAll: (script) => `npm run ${script} --workspaces --if-present`,
181
189
  packageManagerField: null,
182
190
  usesWorkspaceYaml: false,
183
- needsHoistNpmrc: false
191
+ needsHoistNpmrc: false,
192
+ workspaceRange: "*"
184
193
  };
185
194
  }
186
195
  return {
@@ -190,7 +199,8 @@ function jsPmProfile(name) {
190
199
  runAll: (script) => `pnpm -r ${script}`,
191
200
  packageManagerField: PNPM_VERSION,
192
201
  usesWorkspaceYaml: true,
193
- needsHoistNpmrc: true
202
+ needsHoistNpmrc: true,
203
+ workspaceRange: "workspace:*"
194
204
  };
195
205
  }
196
206
  var PIP_SYNC = "python3 -m venv .venv && .venv/bin/python -m pip install -U pip && .venv/bin/python scripts/sync_deps.py";
@@ -210,6 +220,34 @@ function pyPmProfile(name) {
210
220
  needsSyncScript: false
211
221
  };
212
222
  }
223
+ function jsPmInstallPlan(pm) {
224
+ if (pm === "npm") return null;
225
+ return {
226
+ label: "Enabling pnpm via corepack",
227
+ cmd: "corepack",
228
+ args: ["enable", "pnpm"],
229
+ hint: "corepack enable pnpm (or: npm i -g pnpm)"
230
+ };
231
+ }
232
+ function pyPmInstallPlan(pm) {
233
+ if (pm === "pip") return null;
234
+ if (process.platform === "win32") {
235
+ const ps = "irm https://astral.sh/uv/install.ps1 | iex";
236
+ return {
237
+ label: "Installing uv",
238
+ cmd: "powershell",
239
+ args: ["-ExecutionPolicy", "Bypass", "-c", ps],
240
+ hint: `powershell -ExecutionPolicy Bypass -c "${ps}"`
241
+ };
242
+ }
243
+ const sh = "curl -LsSf https://astral.sh/uv/install.sh | sh";
244
+ return {
245
+ label: "Installing uv",
246
+ cmd: "sh",
247
+ args: ["-c", sh],
248
+ hint: sh
249
+ };
250
+ }
213
251
  function hasCommand(cmd) {
214
252
  try {
215
253
  const probe = process.platform === "win32" ? spawnSync("where", [cmd], { stdio: "ignore" }) : spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "ignore" });
@@ -241,73 +279,206 @@ function buildJsWorkspace(ctx) {
241
279
  if (members.length === 0) return null;
242
280
  return "packages:\n" + members.map((m) => ` - "${m}"`).join("\n") + "\n";
243
281
  }
244
- function buildRootPackageJson(ctx) {
282
+ function buildRootPackageJson(ctx, hasDocker) {
245
283
  const anyJs = ctx.hasWeb || ctx.hasMobile || ctx.hasShared;
246
- if (!anyJs) return null;
284
+ if (!anyJs && !ctx.hasServer) return null;
247
285
  const pm = jsPmProfile(ctx.jsPm);
248
- const scripts = {};
249
- if (ctx.hasWeb) scripts["dev:web"] = pm.run("web", "dev");
250
- if (ctx.hasMobile) scripts["dev:mobile"] = pm.run("mobile", "start");
251
- if (ctx.hasApiClient) scripts["gen:api"] = pm.run("@app/api-client", "generate");
252
- scripts["typecheck"] = pm.runAll("typecheck");
286
+ const t = (task) => `node scripts/run.mjs ${task}`;
287
+ const scripts = { bootstrap: t("bootstrap"), dev: t("dev") };
288
+ if (ctx.hasWeb) scripts["web"] = t("web");
289
+ if (ctx.hasMobile) scripts["mobile"] = t("mobile");
290
+ if (ctx.hasServer) {
291
+ scripts["server"] = t("server");
292
+ scripts["migrate"] = t("migrate");
293
+ scripts["superuser"] = t("superuser");
294
+ if (hasDocker) {
295
+ scripts["db:up"] = t("db:up");
296
+ scripts["db:down"] = t("db:down");
297
+ }
298
+ }
299
+ if (ctx.hasApiClient) scripts["gen:api"] = t("gen:api");
300
+ if (anyJs) scripts["typecheck"] = pm.runAll("typecheck");
253
301
  const pkg = {
254
302
  name: ctx.projectSlug,
255
303
  version: "0.1.0",
256
304
  private: true
257
305
  };
258
- if (pm.packageManagerField) pkg.packageManager = pm.packageManagerField;
259
- if (!pm.usesWorkspaceYaml) pkg.workspaces = jsMembers(ctx);
306
+ if (anyJs && pm.packageManagerField) pkg.packageManager = pm.packageManagerField;
307
+ if (anyJs && !pm.usesWorkspaceYaml) pkg.workspaces = jsMembers(ctx);
260
308
  pkg.scripts = scripts;
261
- pkg.devDependencies = { typescript: "^5.7.2" };
309
+ if (anyJs) pkg.devDependencies = { typescript: "^5.7.2" };
262
310
  return JSON.stringify(pkg, null, 2) + "\n";
263
311
  }
312
+ function buildTaskRunner(ctx, hasDocker) {
313
+ const anyJs = ctx.hasWeb || ctx.hasMobile || ctx.hasShared;
314
+ const npm = ctx.jsPm === "npm";
315
+ const pip = ctx.pyPm === "pip";
316
+ const jsInstall = npm ? `run("npm", ["install"])` : `run("pnpm", ["install"])`;
317
+ const jsRun = (pkg, script) => npm ? `run("npm", ["run", "${script}", "-w", "${pkg}"])` : `run("pnpm", ["--filter", "${pkg}", "${script}"])`;
318
+ const pyManage = (...sub) => {
319
+ const args = sub.map((s) => `"${s}"`).join(", ");
320
+ return ctx.pyPm === "uv" ? `run("uv", ["run", "python", "manage.py", ${args}], SERVER)` : `run(venvPy, ["manage.py", ${args}], SERVER)`;
321
+ };
322
+ const serverDev = ctx.pyPm === "uv" ? `["uv", ["run", "python", "manage.py", "runserver", "0.0.0.0:8000"], SERVER]` : `[venvPy, ["manage.py", "runserver", "0.0.0.0:8000"], SERVER]`;
323
+ const jsDevTuple = (pkg, script) => npm ? `["npm", ["run", "${script}", "-w", "${pkg}"]]` : `["pnpm", ["--filter", "${pkg}", "${script}"]]`;
324
+ const devTuples = [];
325
+ if (ctx.hasServer) devTuples.push(serverDev);
326
+ if (ctx.hasWeb) devTuples.push(jsDevTuple("web", "dev"));
327
+ if (ctx.hasMobile) devTuples.push(jsDevTuple("mobile", "start"));
328
+ const needsParallel = devTuples.length > 1;
329
+ const L = [];
330
+ L.push(
331
+ `#!/usr/bin/env node`,
332
+ `// ${ctx.projectName} \u2014 cross-platform task runner (generated by partweave).`,
333
+ `// Run a task on any OS: node scripts/run.mjs <task>`,
334
+ `// Also available as: npm run <task> \xB7 make <task> (on macOS/Linux)`,
335
+ needsParallel ? `import { spawn, spawnSync } from "node:child_process";` : `import { spawnSync } from "node:child_process";`,
336
+ `import { existsSync } from "node:fs";`,
337
+ ``,
338
+ `const isWin = process.platform === "win32";`,
339
+ `const SERVER = { cwd: "apps/server" };`
340
+ );
341
+ if (ctx.hasServer && pip) {
342
+ L.push(`const venvPy = isWin ? ".venv\\\\Scripts\\\\python.exe" : ".venv/bin/python";`);
343
+ }
344
+ L.push(
345
+ ``,
346
+ `// Windows must spawn Node's .cmd shims through a shell; POSIX doesn't.`,
347
+ `function run(cmd, args = [], opts = {}) {`,
348
+ ` const r = spawnSync(cmd, args, { stdio: "inherit", shell: isWin, ...opts });`,
349
+ ` if (r.error) { console.error("\\u2716 " + cmd + ": " + r.error.message); process.exit(1); }`,
350
+ ` if (r.status) process.exit(r.status);`,
351
+ `}`,
352
+ `function has(cmd) {`,
353
+ ` const p = isWin`,
354
+ ` ? spawnSync("where", [cmd], { stdio: "ignore" })`,
355
+ ` : spawnSync("sh", ["-c", "command -v " + cmd], { stdio: "ignore" });`,
356
+ ` return p.status === 0;`,
357
+ `}`,
358
+ ``
359
+ );
360
+ if (needsParallel) {
361
+ L.push(
362
+ `// Run several long-lived dev servers at once; Ctrl-C stops them all.`,
363
+ `function parallel(specs) {`,
364
+ ` const kids = specs.map(([cmd, args, opts = {}]) =>`,
365
+ ` spawn(cmd, args, { stdio: "inherit", shell: isWin, ...opts }));`,
366
+ ` const stop = () => kids.forEach((k) => k.kill());`,
367
+ ` process.on("SIGINT", stop);`,
368
+ ` process.on("SIGTERM", stop);`,
369
+ ` kids.forEach((k) => k.on("exit", (code) => { if (code) { stop(); process.exit(code); } }));`,
370
+ `}`,
371
+ ``
372
+ );
373
+ }
374
+ const ensure = [];
375
+ if (anyJs && ctx.jsPm === "pnpm") {
376
+ ensure.push(
377
+ ` if (!has("pnpm")) {`,
378
+ ` console.log("pnpm not found \\u2014 enabling it via corepack\\u2026");`,
379
+ ` spawnSync("corepack", ["enable", "pnpm"], { stdio: "inherit", shell: isWin });`,
380
+ ` if (!has("pnpm")) { console.error("Could not enable pnpm. Install it with: npm i -g pnpm"); process.exit(1); }`,
381
+ ` }`
382
+ );
383
+ }
384
+ if (ctx.hasServer && ctx.pyPm === "uv") {
385
+ ensure.push(
386
+ ` if (!has("uv")) {`,
387
+ ` console.error("uv not found. Install it, then re-run bootstrap:\\n " + (isWin`,
388
+ ` ? 'powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex"'`,
389
+ ` : "curl -LsSf https://astral.sh/uv/install.sh | sh"));`,
390
+ ` process.exit(1);`,
391
+ ` }`
392
+ );
393
+ }
394
+ if (ctx.hasServer && pip) {
395
+ ensure.push(
396
+ ` if (!has("python3") && !has("python")) { console.error("Python 3 not found. Install Python 3.12+ and re-run."); process.exit(1); }`
397
+ );
398
+ }
399
+ L.push(
400
+ `function ensureTools() {`,
401
+ ...ensure.length ? ensure : [` // npm and pip ship with Node and Python respectively.`],
402
+ `}`,
403
+ ``
404
+ );
405
+ if (ctx.hasServer && pip) {
406
+ L.push(
407
+ `function pipSync() {`,
408
+ ` const py = has("python3") ? "python3" : "python";`,
409
+ ` run(py, ["-m", "venv", ".venv"], SERVER);`,
410
+ ` run(venvPy, ["-m", "pip", "install", "-U", "pip"], SERVER);`,
411
+ ` run(venvPy, ["scripts/sync_deps.py"], SERVER);`,
412
+ `}`,
413
+ ``
414
+ );
415
+ }
416
+ L.push(`const tasks = {`);
417
+ const boot = [` ensureTools();`];
418
+ if (anyJs) boot.push(` ${jsInstall};`);
419
+ if (ctx.hasServer) boot.push(pip ? ` pipSync();` : ` run("uv", ["sync"], SERVER);`);
420
+ L.push(` bootstrap() {`, ...boot, ` },`);
421
+ if (devTuples.length === 1) {
422
+ L.push(` dev() { const [c, a, o = {}] = ${devTuples[0]}; run(c, a, o); },`);
423
+ } else if (devTuples.length > 1) {
424
+ L.push(` dev() { parallel([${devTuples.join(", ")}]); },`);
425
+ }
426
+ if (ctx.hasWeb) L.push(` web() { ${jsRun("web", "dev")}; },`);
427
+ if (ctx.hasMobile) L.push(` mobile() { ${jsRun("mobile", "start")}; },`);
428
+ if (ctx.hasServer) {
429
+ L.push(` server() { ${pyManage("runserver", "0.0.0.0:8000")}; },`);
430
+ L.push(` migrate() { ${pyManage("migrate")}; },`);
431
+ L.push(` superuser() { ${pyManage("createsuperuser")}; },`);
432
+ if (hasDocker) {
433
+ L.push(
434
+ ` "db:up"() {`,
435
+ ` if (spawnSync("docker", ["info"], { stdio: "ignore", shell: isWin }).status) {`,
436
+ ` console.error("Docker isn't running \\u2014 start Docker Desktop and retry."); process.exit(1);`,
437
+ ` }`,
438
+ ` const env = existsSync(".env") ? ["--env-file", ".env"] : [];`,
439
+ ` run("docker", ["compose", ...env, "-f", "infra/docker-compose.yml", "up", "-d", "db"]);`,
440
+ ` },`,
441
+ ` "db:down"() { run("docker", ["compose", "-f", "infra/docker-compose.yml", "down"]); },`
442
+ );
443
+ }
444
+ }
445
+ if (ctx.hasApiClient) L.push(` "gen:api"() { ${jsRun("@app/api-client", "generate")}; },`);
446
+ L.push(`};`, ``);
447
+ L.push(
448
+ `const task = process.argv[2];`,
449
+ `if (!task || !Object.hasOwn(tasks, task)) {`,
450
+ ` console.log("Tasks: " + Object.keys(tasks).join(", "));`,
451
+ ` process.exit(task ? 1 : 0);`,
452
+ `}`,
453
+ `tasks[task]();`,
454
+ ``
455
+ );
456
+ return L.join("\n");
457
+ }
264
458
  function buildMakefile(ctx, hasDocker) {
265
- const js = jsPmProfile(ctx.jsPm);
266
- const py = pyPmProfile(ctx.pyPm);
267
459
  const L = [];
268
460
  const phony = [];
269
- const add = (name, body, help) => {
461
+ const add = (name, task, help) => {
270
462
  phony.push(name);
271
- L.push(`${name}: ## ${help}`);
272
- for (const line of body) L.push(` ${line}`);
273
- L.push("");
463
+ L.push(`${name}: ## ${help}`, ` node scripts/run.mjs ${task}`, "");
274
464
  };
275
- L.push("# " + ctx.projectName + " \u2014 dev tasks");
465
+ L.push("# " + ctx.projectName + " \u2014 dev tasks (wrappers around scripts/run.mjs)");
276
466
  L.push(".DEFAULT_GOAL := help");
277
467
  L.push("");
278
- const bootstrap = [];
279
- if (ctx.hasWeb || ctx.hasMobile || ctx.hasShared) bootstrap.push(js.install);
280
- if (ctx.hasServer) bootstrap.push(`cd apps/server && ${py.syncInServer}`);
281
- if (bootstrap.length) add("bootstrap", bootstrap, "install all dependencies");
468
+ add("bootstrap", "bootstrap", "install all dependencies");
469
+ add("dev", "dev", "run all dev servers");
282
470
  if (ctx.hasServer) {
283
471
  if (hasDocker) {
284
- add(
285
- "db-up",
286
- [
287
- `@docker info >/dev/null 2>&1 || { echo "Docker isn't running \u2014 start Docker Desktop and wait for it to be ready, then retry."; exit 1; }`,
288
- // Load the project's .env (if present) so POSTGRES_* there drive the container.
289
- "docker compose $$([ -f .env ] && echo --env-file .env) -f infra/docker-compose.yml up -d db"
290
- ],
291
- "start Postgres (needs Docker running)"
292
- );
293
- add("db-down", ["docker compose -f infra/docker-compose.yml down"], "stop Postgres");
472
+ add("db-up", "db:up", "start Postgres (needs Docker running)");
473
+ add("db-down", "db:down", "stop Postgres");
294
474
  }
295
- add("migrate", [`cd apps/server && ${py.run("python manage.py migrate")}`], "run migrations");
296
- add(
297
- "server",
298
- [`cd apps/server && ${py.run("python manage.py runserver 0.0.0.0:8000")}`],
299
- "run the Django dev server"
300
- );
301
- add(
302
- "superuser",
303
- [`cd apps/server && ${py.run("python manage.py createsuperuser")}`],
304
- "create an admin user"
305
- );
475
+ add("migrate", "migrate", "run migrations");
476
+ add("server", "server", "run the Django dev server");
477
+ add("superuser", "superuser", "create an admin user");
306
478
  }
307
- if (ctx.hasWeb) add("web", [js.run("web", "dev")], "run the Next.js dev server");
308
- if (ctx.hasMobile) add("mobile", [js.run("mobile", "start")], "run the Expo dev server");
309
- if (ctx.hasApiClient)
310
- add("gen-api", [js.run("@app/api-client", "generate")], "regenerate the typed API client");
479
+ if (ctx.hasWeb) add("web", "web", "run the Next.js dev server");
480
+ if (ctx.hasMobile) add("mobile", "mobile", "run the Expo dev server");
481
+ if (ctx.hasApiClient) add("gen-api", "gen:api", "regenerate the typed API client");
311
482
  L.push(
312
483
  "help: ## show this help",
313
484
  ` @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \\033[36m%-12s\\033[0m %s\\n", $$1, $$2}'`,
@@ -507,10 +678,22 @@ function buildReadme(ctx, modules) {
507
678
  for (const m of modules) parts.push(`- **${m.manifest.title}** \u2014 ${m.manifest.description ?? ""}`);
508
679
  parts.push("");
509
680
  }
510
- parts.push("## Getting started", "", "```sh", "make bootstrap");
511
- if (ctx.hasServer) parts.push("make db-up && make migrate", "make server # http://localhost:8000");
512
- if (ctx.hasWeb) parts.push("make web # http://localhost:3000");
513
- if (ctx.hasMobile) parts.push("make mobile # Expo dev server");
681
+ parts.push(
682
+ "## Getting started",
683
+ "",
684
+ "These `npm run` tasks work the same on macOS, Linux, and Windows (they wrap",
685
+ "`scripts/run.mjs`). On macOS/Linux you can also use `make <task>`.",
686
+ "",
687
+ "```sh",
688
+ "npm run bootstrap"
689
+ );
690
+ const hasDocker = modules.some((m) => m.manifest.id === "docker");
691
+ if (ctx.hasServer) {
692
+ if (hasDocker) parts.push("npm run db:up # start Postgres (needs Docker)");
693
+ parts.push("npm run migrate", "npm run server # http://localhost:8000");
694
+ }
695
+ if (ctx.hasWeb) parts.push("npm run web # http://localhost:3000");
696
+ if (ctx.hasMobile) parts.push("npm run mobile # Expo dev server");
514
697
  parts.push("```", "");
515
698
  parts.push("Copy `.env.example` to `.env` and fill in values before running.", "");
516
699
  return parts.join("\n");
@@ -729,7 +912,7 @@ function injectIntoFiles(index, anchorId, lines, targetLabel) {
729
912
  writeFileSync2(file, next);
730
913
  }
731
914
  }
732
- function applyWiring(outDir, targets, modules) {
915
+ function applyWiring(outDir, targets, modules, workspaceRange) {
733
916
  const indexes = /* @__PURE__ */ new Map();
734
917
  for (const t of targets) {
735
918
  indexes.set(t, buildAnchorIndex(join2(outDir, TARGET_DEST[t])));
@@ -753,7 +936,7 @@ function applyWiring(outDir, targets, modules) {
753
936
  if (existsSync2(pkgPath)) {
754
937
  const merged = mergePackageJsonDeps(
755
938
  readFileSync2(pkgPath, "utf8"),
756
- wiring.deps
939
+ normalizeWorkspaceDeps(wiring.deps, workspaceRange)
757
940
  );
758
941
  writeFileSync2(pkgPath, merged);
759
942
  }
@@ -774,7 +957,8 @@ function applyEnv(outDir, modules) {
774
957
  function writeStructuralRootFiles(outDir, ctx, modules) {
775
958
  const workspace = buildJsWorkspace(ctx);
776
959
  if (workspace) writeFileEnsured(join2(outDir, "pnpm-workspace.yaml"), workspace);
777
- const rootPkg = buildRootPackageJson(ctx);
960
+ const hasDocker = modules.some((m) => m.manifest.id === "docker");
961
+ const rootPkg = buildRootPackageJson(ctx, hasDocker);
778
962
  if (rootPkg) writeFileEnsured(join2(outDir, "package.json"), rootPkg);
779
963
  const turbo = buildTurboJson(ctx);
780
964
  if (turbo) writeFileEnsured(join2(outDir, "turbo.json"), turbo);
@@ -784,7 +968,7 @@ function writeStructuralRootFiles(outDir, ctx, modules) {
784
968
  if (npmrc) writeFileEnsured(join2(outDir, ".npmrc"), npmrc);
785
969
  const pipSync = buildPipSyncScript(ctx);
786
970
  if (pipSync) writeFileEnsured(join2(outDir, "apps/server/scripts/sync_deps.py"), pipSync);
787
- const hasDocker = modules.some((m) => m.manifest.id === "docker");
971
+ writeFileEnsured(join2(outDir, "scripts/run.mjs"), buildTaskRunner(ctx, hasDocker));
788
972
  writeFileEnsured(join2(outDir, "Makefile"), buildMakefile(ctx, hasDocker));
789
973
  writeFileEnsured(join2(outDir, ".env.example"), buildBaseEnv(ctx));
790
974
  }
@@ -814,7 +998,7 @@ function compose(opts) {
814
998
  }
815
999
  }
816
1000
  }
817
- applyWiring(outDir, wireTargets, modules);
1001
+ applyWiring(outDir, wireTargets, modules, jsPmProfile(ctx.jsPm).workspaceRange);
818
1002
  applyEnv(outDir, modules);
819
1003
  if (selection.modules.includes("ci")) {
820
1004
  for (const [rel, content] of Object.entries(buildCiWorkflows(ctx))) {
@@ -831,6 +1015,43 @@ function compose(opts) {
831
1015
  return { written, notes };
832
1016
  }
833
1017
 
1018
+ // src/preflight.ts
1019
+ import { spawnSync as spawnSync2 } from "child_process";
1020
+ import { confirm, isCancel, log, spinner } from "@clack/prompts";
1021
+ function runInstall(plan, probe) {
1022
+ const s = spinner();
1023
+ s.start(plan.label);
1024
+ const r = spawnSync2(plan.cmd, plan.args, {
1025
+ stdio: ["ignore", "ignore", "inherit"],
1026
+ shell: process.platform === "win32"
1027
+ });
1028
+ const ok = r.status === 0 && hasCommand(probe);
1029
+ s.stop(ok ? `${probe} is ready` : `Could not install ${probe}`);
1030
+ return ok;
1031
+ }
1032
+ async function ensurePm(chosen, fallback, plan, opts) {
1033
+ if (hasCommand(chosen)) return chosen;
1034
+ if (!plan) return chosen;
1035
+ let doInstall = opts.install === true;
1036
+ if (opts.interactive) {
1037
+ const answer = await confirm({
1038
+ message: `${chosen} isn't installed. Install it now? (${plan.hint})`
1039
+ });
1040
+ doInstall = !isCancel(answer) && answer === true;
1041
+ }
1042
+ if (doInstall && runInstall(plan, chosen)) return chosen;
1043
+ log.warn(
1044
+ `Using ${fallback} instead of ${chosen}. To use ${chosen} later, run: ${plan.hint}`
1045
+ );
1046
+ return fallback;
1047
+ }
1048
+ function ensureJsPm(pm, opts) {
1049
+ return ensurePm(pm, "npm", jsPmInstallPlan(pm), opts);
1050
+ }
1051
+ function ensurePyPm(pm, opts) {
1052
+ return ensurePm(pm, "pip", pyPmInstallPlan(pm), opts);
1053
+ }
1054
+
834
1055
  // src/registry.ts
835
1056
  import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
836
1057
  import { join as join4 } from "path";
@@ -1004,8 +1225,8 @@ Enable the required app(s) or drop the component(s).`
1004
1225
  // src/prompts.ts
1005
1226
  import {
1006
1227
  cancel,
1007
- confirm,
1008
- isCancel,
1228
+ confirm as confirm2,
1229
+ isCancel as isCancel2,
1009
1230
  multiselect,
1010
1231
  select,
1011
1232
  text
@@ -1014,7 +1235,7 @@ import pc from "picocolors";
1014
1235
  import { resolve as resolve2 } from "path";
1015
1236
  var MULTI_HINT = pc.dim("\u2191/\u2193 move \xB7 space toggle \xB7 a all \xB7 enter confirm");
1016
1237
  function bail(v) {
1017
- if (isCancel(v)) {
1238
+ if (isCancel2(v)) {
1018
1239
  cancel("Cancelled.");
1019
1240
  process.exit(0);
1020
1241
  }
@@ -1092,7 +1313,7 @@ async function promptCreate(registry, defaults) {
1092
1313
  bail(pyRaw);
1093
1314
  pyPm = pyRaw;
1094
1315
  }
1095
- const ok = await confirm({
1316
+ const ok = await confirm2({
1096
1317
  message: `Scaffold ${apps.join(" + ")}${modules.length ? " with " + modules.join(", ") : ""} into ${outDir}?`
1097
1318
  });
1098
1319
  bail(ok);
@@ -1107,7 +1328,7 @@ async function promptCreate(registry, defaults) {
1107
1328
  function resolvePm(value, allowed, detect, flag) {
1108
1329
  if (value === void 0) return detect();
1109
1330
  if (allowed.includes(value)) return value;
1110
- log.error(`Invalid ${flag} "${value}". Choose one of: ${allowed.join(", ")}.`);
1331
+ log2.error(`Invalid ${flag} "${value}". Choose one of: ${allowed.join(", ")}.`);
1111
1332
  process.exit(1);
1112
1333
  }
1113
1334
  function appsFromFlags(flags) {
@@ -1149,26 +1370,33 @@ async function runCreate(flags) {
1149
1370
  });
1150
1371
  }
1151
1372
  if (existsSync5(choices.outDir) && readdirSync3(choices.outDir).length > 0 && !flags.force) {
1152
- log.error(`${choices.outDir} exists and is not empty. Use --force to override.`);
1373
+ log2.error(`${choices.outDir} exists and is not empty. Use --force to override.`);
1153
1374
  process.exit(1);
1154
1375
  }
1376
+ const ensureOpts = { interactive: !nonInteractive, install: flags.install };
1377
+ if (choices.apps.includes("web") || choices.apps.includes("mobile")) {
1378
+ choices.jsPm = await ensureJsPm(choices.jsPm, ensureOpts);
1379
+ }
1380
+ if (choices.apps.includes("server")) {
1381
+ choices.pyPm = await ensurePyPm(choices.pyPm, ensureOpts);
1382
+ }
1155
1383
  let resolved;
1156
1384
  try {
1157
1385
  resolved = resolveModules(registry, choices.modules);
1158
1386
  } catch (err) {
1159
- log.error(err.message);
1387
+ log2.error(err.message);
1160
1388
  process.exit(1);
1161
1389
  }
1162
1390
  if (resolved.autoAdded.length) {
1163
- log.info(`Added required components: ${resolved.autoAdded.join(", ")}`);
1391
+ log2.info(`Added required components: ${resolved.autoAdded.join(", ")}`);
1164
1392
  }
1165
1393
  try {
1166
1394
  validateApps(registry, resolved.modules, choices.apps);
1167
1395
  } catch (err) {
1168
- log.error(err.message);
1396
+ log2.error(err.message);
1169
1397
  process.exit(1);
1170
1398
  }
1171
- const s = spinner();
1399
+ const s = spinner2();
1172
1400
  s.start("Scaffolding");
1173
1401
  let result;
1174
1402
  try {
@@ -1190,7 +1418,7 @@ async function runCreate(flags) {
1190
1418
  });
1191
1419
  } catch (err) {
1192
1420
  s.stop("Failed");
1193
- log.error(err.message);
1421
+ log2.error(err.message);
1194
1422
  process.exit(1);
1195
1423
  }
1196
1424
  s.stop(`Created ${result.written.length} files`);
@@ -1201,25 +1429,52 @@ async function runCreate(flags) {
1201
1429
  jsPm: choices.jsPm,
1202
1430
  pyPm: choices.pyPm
1203
1431
  });
1432
+ let installed = false;
1433
+ if (nonInteractive) {
1434
+ installed = flags.install === true;
1435
+ } else {
1436
+ const ans = await confirm3({ message: "Install dependencies now?" });
1437
+ installed = !isCancel3(ans) && ans === true;
1438
+ }
1439
+ if (installed) {
1440
+ log2.step("Installing dependencies (npm run bootstrap)\u2026");
1441
+ const r = spawnSync3("node", ["scripts/run.mjs", "bootstrap"], {
1442
+ cwd: choices.outDir,
1443
+ stdio: "inherit"
1444
+ });
1445
+ if (r.status) {
1446
+ log2.warn("bootstrap didn't finish cleanly \u2014 run `npm run bootstrap` in the project to retry.");
1447
+ installed = false;
1448
+ }
1449
+ }
1204
1450
  const rel = basename(choices.outDir);
1205
- const steps = [`cd ${rel}`, "make bootstrap"];
1206
- if (choices.apps.includes("server")) steps.push("make db-up && make migrate", "make server");
1207
- if (choices.apps.includes("web")) steps.push("make web");
1208
- if (choices.apps.includes("mobile")) steps.push("make mobile");
1209
- note(steps.join("\n"), "Next steps");
1451
+ const hasDocker = resolved.modules.includes("docker");
1452
+ const steps = [`cd ${rel}`];
1453
+ if (!installed) steps.push("npm run bootstrap");
1454
+ if (choices.apps.includes("server")) {
1455
+ steps.push(hasDocker ? "npm run db:up && npm run migrate" : "npm run migrate");
1456
+ steps.push("npm run server");
1457
+ }
1458
+ if (choices.apps.includes("web")) steps.push("npm run web");
1459
+ if (choices.apps.includes("mobile")) steps.push("npm run mobile");
1460
+ note(
1461
+ steps.join("\n") + "\n\n" + pc2.dim("These work on macOS, Linux & Windows. On macOS/Linux, `make <task>` works too."),
1462
+ "Next steps"
1463
+ );
1210
1464
  if (result.notes.length) note(result.notes.join("\n"), "Notes");
1211
1465
  outro(pc2.green("Done."));
1212
1466
  }
1213
1467
 
1214
1468
  export {
1215
- DEFAULT_JS_PM,
1216
- DEFAULT_PY_PM,
1217
- jsPmProfile,
1218
- pyPmProfile,
1469
+ jsPmInstallPlan,
1470
+ pyPmInstallPlan,
1471
+ hasCommand,
1219
1472
  APPS,
1220
1473
  buildContext,
1221
1474
  selectedTargets,
1222
1475
  compose,
1476
+ ensureJsPm,
1477
+ ensurePyPm,
1223
1478
  Registry,
1224
1479
  readProjectManifest,
1225
1480
  writeProjectManifest,
@@ -1227,4 +1482,4 @@ export {
1227
1482
  validateApps,
1228
1483
  runCreate
1229
1484
  };
1230
- //# sourceMappingURL=chunk-2IGBGILB.js.map
1485
+ //# sourceMappingURL=chunk-XB7H4V4J.js.map
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCreate
4
- } from "./chunk-2IGBGILB.js";
4
+ } from "./chunk-XB7H4V4J.js";
5
5
 
6
6
  // src/create-bin.ts
7
7
  import { Command } from "commander";
package/dist/index.js CHANGED
@@ -1,20 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  APPS,
4
- DEFAULT_JS_PM,
5
- DEFAULT_PY_PM,
6
4
  Registry,
7
5
  buildContext,
8
6
  compose,
9
- jsPmProfile,
10
- pyPmProfile,
7
+ ensureJsPm,
8
+ ensurePyPm,
9
+ hasCommand,
10
+ jsPmInstallPlan,
11
+ pyPmInstallPlan,
11
12
  readProjectManifest,
12
13
  resolveModules,
13
14
  runCreate,
14
15
  selectedTargets,
15
16
  validateApps,
16
17
  writeProjectManifest
17
- } from "./chunk-2IGBGILB.js";
18
+ } from "./chunk-XB7H4V4J.js";
18
19
 
19
20
  // src/index.ts
20
21
  import { Command } from "commander";
@@ -54,13 +55,9 @@ async function runAdd(ids, flags) {
54
55
  let manifest = pm;
55
56
  if (appIds.length) manifest = addApps(registry, dir, manifest, appIds);
56
57
  if (moduleIds.length) manifest = addModules(registry, dir, manifest, moduleIds);
57
- const js = jsPmProfile(manifest.jsPm ?? DEFAULT_JS_PM);
58
- const py = pyPmProfile(manifest.pyPm ?? DEFAULT_PY_PM);
59
- const reinstall = [];
60
- if (manifest.apps.some((a) => a === "web" || a === "mobile")) reinstall.push(js.install);
61
- if (manifest.apps.includes("server"))
62
- reinstall.push(`cd apps/server && ${py.syncInServer} && ${py.run("python manage.py migrate")}`);
63
- if (reinstall.length) note(reinstall.join("\n"), "Sync deps");
58
+ const reinstall = ["npm run bootstrap"];
59
+ if (manifest.apps.includes("server")) reinstall.push("npm run migrate");
60
+ note(reinstall.join("\n"), "Sync deps");
64
61
  outro(pc.green("Done."));
65
62
  }
66
63
  function addApps(registry, dir, pm, appIds) {
@@ -135,17 +132,62 @@ function addModules(registry, dir, pm, moduleIds) {
135
132
  return updated;
136
133
  }
137
134
 
135
+ // src/commands/doctor.ts
136
+ import { spawnSync } from "child_process";
137
+ import { resolve as resolve2 } from "path";
138
+ import { intro as intro2, note as note2, outro as outro2 } from "@clack/prompts";
139
+ import pc2 from "picocolors";
140
+ function version(cmd) {
141
+ const r = spawnSync(cmd, ["--version"], { encoding: "utf8" });
142
+ if (r.error || r.status !== 0) return null;
143
+ return (r.stdout || r.stderr || "").trim().split("\n")[0] ?? "";
144
+ }
145
+ async function runDoctor(flags) {
146
+ intro2(pc2.bgCyan(pc2.black(" partweave ")) + pc2.dim(" doctor"));
147
+ const dir = resolve2(flags.dir ?? process.cwd());
148
+ const project = readProjectManifest(dir);
149
+ const rows = [];
150
+ const check = (label, cmd, optional = false) => {
151
+ const v = version(cmd);
152
+ const ok = v !== null;
153
+ const mark = ok ? pc2.green("\u2713") : optional ? pc2.yellow("\u25CB") : pc2.red("\u2717");
154
+ const detail = ok ? pc2.dim(v) : pc2.dim(optional ? "not found (optional)" : "not found");
155
+ rows.push(`${mark} ${label.padEnd(9)} ${detail}`);
156
+ return ok;
157
+ };
158
+ check("node", "node");
159
+ check("npm", "npm");
160
+ check("pnpm", "pnpm", true);
161
+ check("uv", "uv", true);
162
+ check("python", process.platform === "win32" ? "python" : "python3", true);
163
+ check("docker", "docker", true);
164
+ check("make", "make", true);
165
+ note2(rows.join("\n"), project ? `Environment for ${project.name}` : "Environment");
166
+ const jsPm = project?.jsPm ?? "pnpm";
167
+ const pyPm = project?.pyPm ?? "uv";
168
+ if (jsPmInstallPlan(jsPm) && !hasCommand(jsPm)) {
169
+ await ensureJsPm(jsPm, { interactive: true });
170
+ }
171
+ if (pyPmInstallPlan(pyPm) && !hasCommand(pyPm)) {
172
+ await ensurePyPm(pyPm, { interactive: true });
173
+ }
174
+ outro2(pc2.green("Done."));
175
+ }
176
+
138
177
  // src/index.ts
139
178
  function buildProgram() {
140
179
  const program = new Command();
141
180
  program.name("partweave").description("A modular full-stack scaffolder \u2014 pick parts, generate only that code.").version("0.1.0");
142
- program.command("create", { isDefault: true }).argument("[name]", "project name").description("Scaffold a new project").option("-d, --dir <path>", "target directory").option("--server", "include the Django server").option("--no-server", "exclude the server").option("--web", "include the Next.js web app").option("--no-web", "exclude the web app").option("--mobile", "include the Expo mobile app").option("--no-mobile", "exclude the mobile app").option("--with <ids>", "comma-separated component ids (e.g. auth,docker,ci)").option("--js-pm <pm>", "JS package manager: pnpm | npm (default: auto-detect)").option("--py-pm <pm>", "Python package manager: uv | pip (default: auto-detect)").option("-y, --yes", "skip prompts; use flags/defaults").option("-f, --force", "write into a non-empty directory").action(async (name, opts) => {
181
+ program.command("create", { isDefault: true }).argument("[name]", "project name").description("Scaffold a new project").option("-d, --dir <path>", "target directory").option("--server", "include the Django server").option("--no-server", "exclude the server").option("--web", "include the Next.js web app").option("--no-web", "exclude the web app").option("--mobile", "include the Expo mobile app").option("--no-mobile", "exclude the mobile app").option("--with <ids>", "comma-separated component ids (e.g. auth,docker,ci)").option("--js-pm <pm>", "JS package manager: pnpm | npm (default: auto-detect)").option("--py-pm <pm>", "Python package manager: uv | pip (default: auto-detect)").option("-y, --yes", "skip prompts; use flags/defaults").option("-f, --force", "write into a non-empty directory").option("--install", "install dependencies after scaffolding (default: ask, off with --yes)").action(async (name, opts) => {
143
182
  const flags = { ...opts, name: name ?? opts.name };
144
183
  await runCreate(flags);
145
184
  });
146
185
  program.command("add").argument("<items...>", "apps (server/web/mobile) or component ids to add").description("Add an app or component to an existing generated project").option("-d, --dir <path>", "project directory (default: cwd)").action(async (items, opts) => {
147
186
  await runAdd(items, opts);
148
187
  });
188
+ program.command("doctor").description("Check your environment and install any missing package managers").option("-d, --dir <path>", "project directory (default: cwd)").action(async (opts) => {
189
+ await runDoctor(opts);
190
+ });
149
191
  return program;
150
192
  }
151
193
  async function main(argv = process.argv) {
@@ -159,6 +201,7 @@ export {
159
201
  buildProgram,
160
202
  main,
161
203
  runAdd,
162
- runCreate
204
+ runCreate,
205
+ runDoctor
163
206
  };
164
207
  //# sourceMappingURL=index.js.map
@@ -13,6 +13,8 @@ config.resolver.nodeModulesPaths = [
13
13
  path.resolve(projectRoot, "node_modules"),
14
14
  path.resolve(workspaceRoot, "node_modules"),
15
15
  ];
16
- config.resolver.disableHierarchicalLookup = true;
16
+ // Keep Metro's default hierarchical node_modules lookup ON: under npm (and any
17
+ // non-hoisted install) some Expo sub-deps (e.g. expo-asset) resolve from
18
+ // expo/node_modules, and disabling the walk-up breaks bundling them.
17
19
 
18
20
  module.exports = config;
@@ -29,8 +29,10 @@
29
29
  "@testing-library/react-native": "^13.2.0",
30
30
  "@types/jest": "^29.5.14",
31
31
  "@types/react": "~19.1.10",
32
+ "babel-preset-expo": "~54.0.7",
32
33
  "jest": "^29.7.0",
33
34
  "jest-expo": "~54.0.0",
35
+ "react-test-renderer": "19.1.0",
34
36
  "typescript": "~5.9.2"
35
37
  }
36
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partweave",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A modular full-stack scaffolder — pick the parts you need (Django server, Next.js web, Expo mobile) and generate only that code, wired together.",
5
5
  "type": "module",
6
6
  "license": "MIT",