partweave 0.3.1 → 0.3.3

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.
@@ -4,8 +4,100 @@
4
4
  import { spawnSync as spawnSync3 } from "child_process";
5
5
  import { existsSync as existsSync5, readdirSync as readdirSync3 } from "fs";
6
6
  import { basename, resolve as resolve3 } from "path";
7
- import { confirm as confirm3, intro, isCancel as isCancel3, log as log2, note, outro, spinner as spinner2 } from "@clack/prompts";
8
- import pc2 from "picocolors";
7
+ import { confirm as confirm3, intro, isCancel as isCancel3, log as log2, note as note2, outro, spinner as spinner2 } from "@clack/prompts";
8
+ import pc3 from "picocolors";
9
+
10
+ // src/banner.ts
11
+ import pc from "picocolors";
12
+ var GLYPHS = {
13
+ p: ["\u2588\u2588\u2588 ", "\u2588 \u2588", "\u2588\u2588\u2588 ", "\u2588 ", "\u2588 "],
14
+ a: [" \u2588\u2588 ", "\u2588 \u2588", "\u2588\u2588\u2588\u2588", "\u2588 \u2588", "\u2588 \u2588"],
15
+ r: ["\u2588\u2588\u2588 ", "\u2588 \u2588", "\u2588\u2588\u2588 ", "\u2588 \u2588 ", "\u2588 \u2588"],
16
+ t: ["\u2588\u2588\u2588\u2588\u2588", " \u2588 ", " \u2588 ", " \u2588 ", " \u2588 "],
17
+ w: ["\u2588 \u2588", "\u2588 \u2588", "\u2588 \u2588 \u2588", "\u2588\u2588 \u2588\u2588", "\u2588 \u2588"],
18
+ e: ["\u2588\u2588\u2588\u2588", "\u2588 ", "\u2588\u2588\u2588 ", "\u2588 ", "\u2588\u2588\u2588\u2588"],
19
+ v: ["\u2588 \u2588", "\u2588 \u2588", "\u2588 \u2588", " \u2588 \u2588 ", " \u2588 "]
20
+ };
21
+ var weaveCell = (row, col) => (row + col) % 2 ? "\u2593" : "\u2588";
22
+ var LOOM = ["\u2503\u2501\u2503\u2501", "\u2501\u2503\u2501\u2503", "\u2503\u2501\u2503\u2501", "\u2501\u2503\u2501\u2503", "\u2503\u2501\u2503\u2501"];
23
+ var WORD = "partweave";
24
+ var HEIGHT = 5;
25
+ function colorMode() {
26
+ if (process.env.NO_COLOR || !process.stdout.isTTY) return "none";
27
+ if (/truecolor|24bit/i.test(process.env.COLORTERM ?? "")) return "truecolor";
28
+ return "basic";
29
+ }
30
+ var STOPS = [
31
+ [124, 92, 246],
32
+ // violet
33
+ [190, 75, 219],
34
+ // magenta
35
+ [232, 74, 143],
36
+ // pink
37
+ [225, 51, 74],
38
+ // red
39
+ [248, 96, 76]
40
+ // coral / bright red
41
+ ];
42
+ function gradient(t) {
43
+ const n = STOPS.length - 1;
44
+ const scaled = Math.min(Math.max(t, 0), 1) * n;
45
+ const i = Math.min(Math.floor(scaled), n - 1);
46
+ const local = scaled - i;
47
+ const [a, b] = [STOPS[i], STOPS[i + 1]];
48
+ return [0, 1, 2].map((k) => Math.round(a[k] + (b[k] - a[k]) * local));
49
+ }
50
+ function paintFace(ch, t, mode) {
51
+ if (ch === " ") return " ";
52
+ if (mode === "none") return ch;
53
+ if (mode === "basic") return t < 0.5 ? pc.magentaBright(ch) : pc.redBright(ch);
54
+ const [r, g, b] = gradient(t);
55
+ return `\x1B[38;2;${r};${g};${b}m${ch}\x1B[39m`;
56
+ }
57
+ function paintShadow(ch, mode) {
58
+ if (ch === " ") return " ";
59
+ if (mode === "none") return ch;
60
+ if (mode === "basic") return pc.gray(ch);
61
+ return `\x1B[38;2;110;110;110m${ch}\x1B[39m`;
62
+ }
63
+ function paintThread(ch, mode) {
64
+ if (ch === " ") return " ";
65
+ if (mode === "none") return ch;
66
+ if (mode === "basic") return pc.dim(ch);
67
+ return `\x1B[38;2;120;110;140m${ch}\x1B[39m`;
68
+ }
69
+ function word2tone(word, mode) {
70
+ if (mode === "none") return word;
71
+ return [...word].map((ch, i) => paintFace(ch, word.length > 1 ? i / (word.length - 1) : 0, mode)).join("");
72
+ }
73
+ function compact(mode) {
74
+ return " " + (mode === "none" ? "partweave" : word2tone("partweave", mode));
75
+ }
76
+ function renderBanner() {
77
+ const mode = colorMode();
78
+ if (!process.stdout.isTTY) return compact(mode);
79
+ const rows = [];
80
+ for (let r = 0; r < HEIGHT; r++) {
81
+ rows.push([...WORD].map((ch) => GLYPHS[ch]?.[r] ?? "").join(" "));
82
+ }
83
+ const glyphW = rows[0].length;
84
+ const total = 2 + LOOM[0].length + 1 + (glyphW + 1);
85
+ if (total > (process.stdout.columns ?? 80)) return compact(mode);
86
+ const front = (r, c) => r >= 0 && r < HEIGHT && c >= 0 && c < glyphW && rows[r][c] === "\u2588";
87
+ const out = [];
88
+ for (let R = 0; R < HEIGHT; R++) {
89
+ const loom = [...LOOM[R]].map((ch) => paintThread(ch, mode)).join("");
90
+ let mark = "";
91
+ for (let C = 0; C <= glyphW; C++) {
92
+ const t = glyphW > 1 ? Math.min(C, glyphW - 1) / (glyphW - 1) : 0;
93
+ if (front(R, C)) mark += paintFace(weaveCell(R, C), t, mode);
94
+ else if (front(R, C - 1)) mark += paintShadow("\u2591", mode);
95
+ else mark += " ";
96
+ }
97
+ out.push(" " + loom + " " + mark);
98
+ }
99
+ return out.join("\n");
100
+ }
9
101
 
10
102
  // src/compose.ts
11
103
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
@@ -26,14 +118,19 @@ function injectAtAnchor(content, anchorId, lines) {
26
118
  throw new Error(`Anchor <partweave:${anchorId}> not found in target file`);
27
119
  }
28
120
  const indent = match[1] ?? "";
29
- const existing = new Set(
30
- content.split("\n").map((l) => l.trim()).filter(Boolean)
31
- );
32
- const toInsert = lines.filter((l) => !existing.has(l.trim()));
121
+ const before = content.slice(0, match.index).split("\n");
122
+ if (before[before.length - 1] === "") before.pop();
123
+ const block = /* @__PURE__ */ new Set();
124
+ for (let i = before.length - 1; i >= 0; i--) {
125
+ const trimmed = before[i].trim();
126
+ if (trimmed === "") break;
127
+ block.add(trimmed);
128
+ }
129
+ const toInsert = lines.filter((l) => !block.has(l.trim()));
33
130
  if (toInsert.length === 0) return { content, inserted: 0 };
34
- const block = toInsert.map((l) => l ? indent + l : "").join("\n") + "\n";
131
+ const insertion = toInsert.map((l) => l ? indent + l : "").join("\n") + "\n";
35
132
  const anchorStart = match.index;
36
- const newContent = content.slice(0, anchorStart) + block + content.slice(anchorStart);
133
+ const newContent = content.slice(0, anchorStart) + insertion + content.slice(anchorStart);
37
134
  return { content: newContent, inserted: toInsert.length };
38
135
  }
39
136
  function parseDep(dep) {
@@ -50,34 +147,35 @@ function normalizeWorkspaceDeps(deps, workspaceRange) {
50
147
  return dep;
51
148
  });
52
149
  }
150
+ function higherVersion(a, b) {
151
+ const core = (v) => {
152
+ const m = v.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
153
+ if (!m || m[1] === void 0) return null;
154
+ return [m[1], m[2], m[3]].map((n) => n === void 0 ? 0 : Number(n));
155
+ };
156
+ const ca = core(a);
157
+ const cb = core(b);
158
+ if (!ca || !cb) return a;
159
+ for (let i = 0; i < 3; i++) {
160
+ if (ca[i] !== cb[i]) return ca[i] > cb[i] ? a : b;
161
+ }
162
+ return a;
163
+ }
53
164
  function mergePackageJsonDeps(pkgJson, deps, field = "dependencies") {
54
165
  if (deps.length === 0) return pkgJson;
55
166
  const pkg = JSON.parse(pkgJson);
56
167
  const bucket = pkg[field] ?? {};
57
168
  for (const dep of deps) {
58
169
  const { name, version } = parseDep(dep);
59
- if (!(name in bucket)) bucket[name] = version;
170
+ bucket[name] = name in bucket ? higherVersion(bucket[name], version) : version;
60
171
  }
61
172
  pkg[field] = Object.fromEntries(
62
173
  Object.entries(bucket).sort(([a], [b]) => a.localeCompare(b))
63
174
  );
64
175
  return JSON.stringify(pkg, null, 2) + "\n";
65
176
  }
66
- function appendEnv(envBody, entries, heading) {
67
- const keys = Object.keys(entries);
68
- if (keys.length === 0) return envBody;
69
- const present = new Set(
70
- envBody.split("\n").map((l) => l.split("=")[0]?.trim()).filter(Boolean)
71
- );
72
- const missing = keys.filter((k) => !present.has(k));
73
- if (missing.length === 0) return envBody;
74
- let out = envBody.replace(/\n*$/, "\n");
75
- if (heading) out += `
76
- # ${heading}
77
- `;
78
- for (const k of missing) out += `${k}=${entries[k]}
79
- `;
80
- return out;
177
+ function pyDepName(dep) {
178
+ return dep.trim().replace(/^["']|["']$/g, "").split(/[<>=!~;[\s]/)[0].trim().toLowerCase();
81
179
  }
82
180
 
83
181
  // src/fsutil.ts
@@ -548,45 +646,82 @@ function buildTsconfigBase(ctx) {
548
646
  2
549
647
  ) + "\n";
550
648
  }
551
- function buildBaseEnv(ctx) {
552
- const lines = [
553
- "# Environment for " + ctx.projectName,
554
- "#",
555
- "# Every value the app reads from the environment lives here. Copy this file to",
556
- "# `.env` and adjust as needed \u2014 the server, web/mobile apps, and each component",
557
- "# read their configuration from it, so there's nothing to configure elsewhere.",
558
- ""
559
- ];
649
+ function envScopeFor(key) {
650
+ if (key.startsWith("POSTGRES_")) return "root";
651
+ if (key.startsWith("NEXT_PUBLIC_")) return "web";
652
+ if (key.startsWith("EXPO_PUBLIC_")) return "mobile";
653
+ return "server";
654
+ }
655
+ function buildEnvFiles(ctx, modules) {
656
+ const componentBlocks = { server: [], web: [], mobile: [], root: [] };
657
+ for (const m of modules) {
658
+ const byScope = { server: [], web: [], mobile: [], root: [] };
659
+ for (const [key, value] of Object.entries(m.manifest.env)) {
660
+ byScope[envScopeFor(key)].push(`${key}=${value}`);
661
+ }
662
+ for (const scope of ["server", "web", "mobile", "root"]) {
663
+ if (byScope[scope].length === 0) continue;
664
+ componentBlocks[scope].push(`# ${m.manifest.title}`, ...byScope[scope], "");
665
+ }
666
+ }
667
+ const render2 = (lines) => lines.join("\n").replace(/\n*$/, "\n");
668
+ const files = [];
560
669
  if (ctx.hasServer) {
561
- lines.push(
562
- "# --- server ---",
563
- "# Unique per-project key generated at scaffold time; signs sessions and JWTs.",
564
- "# Use a separate secret value in production (and never commit the real one).",
565
- `DJANGO_SECRET_KEY=${randomBytes(48).toString("base64url")}`,
670
+ const serverBase = (secretLine) => [
671
+ `# ${ctx.projectName} \u2014 server (Django). Read by apps/server.`,
672
+ "# `.env` is gitignored; keep real secrets here, not in `.env.example`.",
673
+ "",
674
+ "# Unique per-project key; signs sessions and JWTs.",
675
+ secretLine,
566
676
  "DJANGO_DEBUG=true",
567
- "DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1",
568
- "# Web origins allowed to call the API when DEBUG is off (comma-separated).",
569
- "# In DEBUG every origin is allowed, so this is only needed in production.",
677
+ "# Leave DJANGO_ALLOWED_HOSTS unset in dev: with DEBUG on, any host is allowed,",
678
+ "# so a phone/simulator can reach the server over your LAN (e.g. 192.168.x.y).",
679
+ "# Set it (comma-separated) in production, e.g. DJANGO_ALLOWED_HOSTS=api.example.com",
680
+ "# Origins allowed to call the API when DEBUG is off (comma-separated).",
570
681
  "# DJANGO_CORS_ALLOWED_ORIGINS=https://app.example.com",
571
- "# DATABASE_URL \u2014 the server uses local SQLite by default; set a database URL",
572
- "# to switch (the `db-postgres` component sets a Postgres DSN here for you).",
682
+ "# DATABASE_URL \u2014 unset uses local SQLite; the db-postgres component sets a Postgres DSN.",
573
683
  ""
574
- );
684
+ ];
685
+ files.push({
686
+ dir: "apps/server",
687
+ example: render2([...serverBase("DJANGO_SECRET_KEY=replace-with-a-generated-secret"), ...componentBlocks.server]),
688
+ env: render2([...serverBase(`DJANGO_SECRET_KEY=${randomBytes(48).toString("base64url")}`), ...componentBlocks.server])
689
+ });
575
690
  }
576
691
  if (ctx.hasWeb) {
577
- lines.push("# --- web ---", "NEXT_PUBLIC_API_URL=http://localhost:8000", "");
692
+ const web = [
693
+ `# ${ctx.projectName} \u2014 web (Next.js). Read by apps/web.`,
694
+ "# Only NEXT_PUBLIC_* is exposed to the browser.",
695
+ "NEXT_PUBLIC_API_URL=http://localhost:8000",
696
+ "",
697
+ ...componentBlocks.web
698
+ ];
699
+ const body = render2(web);
700
+ files.push({ dir: "apps/web", example: body, env: body });
578
701
  }
579
702
  if (ctx.hasMobile) {
580
- lines.push(
581
- "# --- mobile (Expo) ---",
582
- "# In dev the app auto-detects your machine's LAN IP so a physical device can",
583
- "# reach the server \u2014 leave this unset. Set it for simulators or production,",
584
- "# in the environment or apps/mobile/.env (Expo reads EXPO_PUBLIC_* from there).",
703
+ const mobile = [
704
+ `# ${ctx.projectName} \u2014 mobile (Expo). Read by apps/mobile.`,
705
+ "# Only EXPO_PUBLIC_* is exposed to the app. In dev the app auto-detects your",
706
+ "# machine's LAN IP, so this can stay unset; set it for simulators/device/production.",
585
707
  "# EXPO_PUBLIC_API_URL=http://localhost:8000",
586
- ""
587
- );
588
- }
589
- return lines.join("\n");
708
+ "",
709
+ ...componentBlocks.mobile
710
+ ];
711
+ const body = render2(mobile);
712
+ files.push({ dir: "apps/mobile", example: body, env: body });
713
+ }
714
+ if (componentBlocks.root.length > 0) {
715
+ const root = [
716
+ `# ${ctx.projectName} \u2014 infrastructure. Read by docker compose (--env-file .env).`,
717
+ "# Keep these in sync with DATABASE_URL in apps/server/.env.",
718
+ "",
719
+ ...componentBlocks.root
720
+ ];
721
+ const body = render2(root);
722
+ files.push({ dir: "", example: body, env: body });
723
+ }
724
+ return files;
590
725
  }
591
726
  function jsCiSteps(ctx) {
592
727
  if (ctx.jsPm === "npm") {
@@ -711,7 +846,18 @@ function buildReadme(ctx, modules) {
711
846
  if (ctx.hasWeb) parts.push("npm run web # http://localhost:3000");
712
847
  if (ctx.hasMobile) parts.push("npm run mobile # Expo dev server");
713
848
  parts.push("```", "");
714
- parts.push("Copy `.env.example` to `.env` and fill in values before running.", "");
849
+ parts.push("## Configuration", "");
850
+ parts.push(
851
+ "Each app reads its **own** env file, created for you (and gitignored) with a",
852
+ "committed `.env.example` template alongside it. Edit the `.env` to change values:",
853
+ ""
854
+ );
855
+ const envFiles = [];
856
+ if (ctx.hasServer) envFiles.push("- `apps/server/.env` \u2014 Django secret key, allowed hosts, `DATABASE_URL`");
857
+ if (ctx.hasWeb) envFiles.push("- `apps/web/.env` \u2014 `NEXT_PUBLIC_*` (browser-exposed)");
858
+ if (ctx.hasMobile) envFiles.push("- `apps/mobile/.env` \u2014 `EXPO_PUBLIC_*` (app-exposed)");
859
+ if (hasDocker) envFiles.push("- `.env` (root) \u2014 `POSTGRES_*` for the database container");
860
+ parts.push(...envFiles, "");
715
861
  return parts.join("\n");
716
862
  }
717
863
  function buildServerDockerfile(ctx) {
@@ -838,7 +984,7 @@ var ManifestSchema = z.object({
838
984
  conflicts: z.array(z.string()).default([]),
839
985
  /** capability/interface this module satisfies (for conflict grouping) */
840
986
  provides: z.string().optional(),
841
- /** env keys → default value, appended to .env.example */
987
+ /** env keys → default value; routed to the consuming app's .env/.env.example by prefix (POSTGRES_→root, NEXT_PUBLIC_→web, EXPO_PUBLIC_→mobile, else server) */
842
988
  env: z.record(z.string()).default({}),
843
989
  /** per-target wiring (files are copied separately from wiring injection) */
844
990
  wiring: z.record(z.enum(TARGETS), WiringForTargetSchema).default({}),
@@ -928,6 +1074,25 @@ function injectIntoFiles(index, anchorId, lines, targetLabel) {
928
1074
  writeFileSync2(file, next);
929
1075
  }
930
1076
  }
1077
+ function injectPyDeps(index, deps, targetLabel) {
1078
+ const files = index.get("deps");
1079
+ if (!files || files.length === 0) {
1080
+ throw new Error(
1081
+ `Wiring error: anchor <partweave:deps> not found in ${targetLabel}. Add the anchor to the _core/${targetLabel} scaffold.`
1082
+ );
1083
+ }
1084
+ for (const file of files) {
1085
+ const content = readFileSync2(file, "utf8");
1086
+ const region = content.match(/dependencies\s*=\s*\[([\s\S]*?)\]/)?.[1] ?? content;
1087
+ const present = new Set(
1088
+ [...region.matchAll(/["']([^"']+)["']/g)].map((m) => pyDepName(m[1]))
1089
+ );
1090
+ const fresh = deps.filter((d) => !present.has(pyDepName(d)));
1091
+ if (fresh.length === 0) continue;
1092
+ const { content: next } = injectAtAnchor(content, "deps", fresh.map((d) => `"${d}",`));
1093
+ writeFileSync2(file, next);
1094
+ }
1095
+ }
931
1096
  function applyWiring(outDir, targets, modules, workspaceRange) {
932
1097
  const indexes = /* @__PURE__ */ new Map();
933
1098
  for (const t of targets) {
@@ -945,8 +1110,7 @@ function applyWiring(outDir, targets, modules, workspaceRange) {
945
1110
  }
946
1111
  if (wiring.deps?.length) {
947
1112
  if (t === "server") {
948
- const lines = wiring.deps.map((d) => `"${d}",`);
949
- injectIntoFiles(index, "deps", lines, t);
1113
+ injectPyDeps(index, wiring.deps, t);
950
1114
  } else {
951
1115
  const pkgPath = join2(targetDir, "package.json");
952
1116
  if (existsSync2(pkgPath)) {
@@ -961,14 +1125,19 @@ function applyWiring(outDir, targets, modules, workspaceRange) {
961
1125
  }
962
1126
  }
963
1127
  }
964
- function applyEnv(outDir, modules) {
965
- const envPath = join2(outDir, ".env.example");
966
- let body = existsSync2(envPath) ? readFileSync2(envPath, "utf8") : "";
967
- for (const mod of modules) {
968
- if (Object.keys(mod.manifest.env).length === 0) continue;
969
- body = appendEnv(body, mod.manifest.env, mod.manifest.title);
1128
+ function writeEnvFiles(outDir, ctx, modules) {
1129
+ const written = [];
1130
+ for (const file of buildEnvFiles(ctx, modules)) {
1131
+ const exampleRel = join2(file.dir, ".env.example");
1132
+ writeFileEnsured(join2(outDir, exampleRel), file.example);
1133
+ written.push(exampleRel);
1134
+ const envRel = join2(file.dir, ".env");
1135
+ if (!existsSync2(join2(outDir, envRel))) {
1136
+ writeFileEnsured(join2(outDir, envRel), file.env);
1137
+ written.push(envRel);
1138
+ }
970
1139
  }
971
- if (body) writeFileSync2(envPath, body);
1140
+ return written;
972
1141
  }
973
1142
  function writeStructuralRootFiles(outDir, ctx, modules) {
974
1143
  const workspace = buildJsWorkspace(ctx);
@@ -984,7 +1153,6 @@ function writeStructuralRootFiles(outDir, ctx, modules) {
984
1153
  if (pipSync) writeFileEnsured(join2(outDir, "apps/server/scripts/sync_deps.py"), pipSync);
985
1154
  writeFileEnsured(join2(outDir, "scripts/run.mjs"), buildTaskRunner(ctx, hasDocker));
986
1155
  writeFileEnsured(join2(outDir, "Makefile"), buildMakefile(ctx, hasDocker));
987
- writeFileEnsured(join2(outDir, ".env.example"), buildBaseEnv(ctx));
988
1156
  }
989
1157
  function compose(opts) {
990
1158
  const { selection, registry, scaffoldTargets, wireTargets, rootFiles } = opts;
@@ -1013,7 +1181,7 @@ function compose(opts) {
1013
1181
  }
1014
1182
  }
1015
1183
  applyWiring(outDir, wireTargets, modules, jsPmProfile(ctx.jsPm).workspaceRange);
1016
- applyEnv(outDir, modules);
1184
+ if (rootFiles !== "none") written.push(...writeEnvFiles(outDir, ctx, modules));
1017
1185
  if (selection.modules.includes("ci")) {
1018
1186
  for (const [rel, content] of Object.entries(buildCiWorkflows(ctx))) {
1019
1187
  writeFileEnsured(join2(outDir, rel), content);
@@ -1209,11 +1377,28 @@ function resolveModules(registry, chosen) {
1209
1377
  provided.set(cap, id);
1210
1378
  }
1211
1379
  }
1212
- const order = [...resolved].sort((a, b) => {
1213
- const da = registry.require(a).manifest.requires.length;
1214
- const db = registry.require(b).manifest.requires.length;
1215
- return da - db || a.localeCompare(b);
1216
- });
1380
+ const ids = [...resolved];
1381
+ const deps = new Map(
1382
+ ids.map((id) => [id, registry.require(id).manifest.requires.filter((r) => resolved.has(r))])
1383
+ );
1384
+ const indegree = new Map(ids.map((id) => [id, deps.get(id).length]));
1385
+ const dependents = new Map(ids.map((id) => [id, []]));
1386
+ for (const id of ids) for (const d of deps.get(id)) dependents.get(d).push(id);
1387
+ const byId = (a, b) => a.localeCompare(b);
1388
+ const ready = ids.filter((id) => indegree.get(id) === 0).sort(byId);
1389
+ const order = [];
1390
+ while (ready.length > 0) {
1391
+ const id = ready.shift();
1392
+ order.push(id);
1393
+ for (const dep of dependents.get(id)) {
1394
+ const n = indegree.get(dep) - 1;
1395
+ indegree.set(dep, n);
1396
+ if (n === 0) {
1397
+ ready.push(dep);
1398
+ ready.sort(byId);
1399
+ }
1400
+ }
1401
+ }
1217
1402
  return { modules: order, autoAdded: [...autoAdded].sort() };
1218
1403
  }
1219
1404
  function validateApps(registry, moduleIds, apps) {
@@ -1242,12 +1427,13 @@ import {
1242
1427
  confirm as confirm2,
1243
1428
  isCancel as isCancel2,
1244
1429
  multiselect,
1430
+ note,
1245
1431
  select,
1246
1432
  text
1247
1433
  } from "@clack/prompts";
1248
- import pc from "picocolors";
1434
+ import pc2 from "picocolors";
1249
1435
  import { resolve as resolve2 } from "path";
1250
- var MULTI_HINT = pc.dim("\u2191/\u2193 move \xB7 space toggle \xB7 a all \xB7 enter confirm");
1436
+ var MULTI_HINT = pc2.dim("\u2191/\u2193 move \xB7 space toggle \xB7 a all \xB7 enter confirm");
1251
1437
  function bail(v) {
1252
1438
  if (isCancel2(v)) {
1253
1439
  cancel("Cancelled.");
@@ -1327,9 +1513,19 @@ async function promptCreate(registry, defaults) {
1327
1513
  bail(pyRaw);
1328
1514
  pyPm = pyRaw;
1329
1515
  }
1330
- const ok = await confirm2({
1331
- message: `Scaffold ${apps.join(" + ")}${modules.length ? " with " + modules.join(", ") : ""} into ${outDir}?`
1332
- });
1516
+ const row = (k, v) => `${pc2.dim(k.padEnd(8))} ${v}`;
1517
+ const tooling = [anyJs ? jsPm : null, apps.includes("server") ? pyPm : null].filter(Boolean).join(pc2.dim(" \xB7 "));
1518
+ note(
1519
+ [
1520
+ row("project", pc2.bold(projectName)),
1521
+ row("where", outDir),
1522
+ row("apps", apps.join(pc2.dim(" \xB7 "))),
1523
+ row("add-ons", modules.length ? modules.join(pc2.dim(" \xB7 ")) : pc2.dim("none")),
1524
+ row("tooling", tooling)
1525
+ ].join("\n"),
1526
+ "Review"
1527
+ );
1528
+ const ok = await confirm2({ message: "Scaffold this?" });
1333
1529
  bail(ok);
1334
1530
  if (!ok) {
1335
1531
  cancel("Cancelled.");
@@ -1339,6 +1535,21 @@ async function promptCreate(registry, defaults) {
1339
1535
  }
1340
1536
 
1341
1537
  // src/commands/create.ts
1538
+ function isInsideGitRepo(dir) {
1539
+ return spawnSync3("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir, stdio: "ignore" }).status === 0;
1540
+ }
1541
+ function initGit(dir) {
1542
+ if (spawnSync3("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" }).status !== 0) {
1543
+ spawnSync3("git", ["init"], { cwd: dir, stdio: "ignore" });
1544
+ spawnSync3("git", ["branch", "-M", "main"], { cwd: dir, stdio: "ignore" });
1545
+ }
1546
+ spawnSync3("git", ["add", "-A"], { cwd: dir, stdio: "ignore" });
1547
+ const msg = "Initial commit (scaffolded with partweave)";
1548
+ const opts = { cwd: dir, stdio: "ignore" };
1549
+ if (spawnSync3("git", ["commit", "-m", msg], opts).status === 0) return true;
1550
+ const id = ["-c", "user.name=partweave", "-c", "user.email=partweave@users.noreply.github.com"];
1551
+ return spawnSync3("git", [...id, "commit", "-m", msg], opts).status === 0;
1552
+ }
1342
1553
  function resolvePm(value, allowed, detect, flag) {
1343
1554
  if (value === void 0) return detect();
1344
1555
  if (allowed.includes(value)) return value;
@@ -1360,7 +1571,8 @@ function defaultModules(registry, apps) {
1360
1571
  return registry.features().filter((m) => m.manifest.default).filter((m) => m.manifest.targets.some((t) => present.has(t))).filter((m) => m.manifest.requiresApps.every((a) => apps.includes(a))).map((m) => m.manifest.id);
1361
1572
  }
1362
1573
  async function runCreate(flags) {
1363
- intro(pc2.bgCyan(pc2.black(" partweave ")) + pc2.dim(" full-stack scaffolder"));
1574
+ console.log("\n" + renderBanner());
1575
+ intro(pc3.dim("full-stack scaffolder \u2014 pick the parts, own the code"));
1364
1576
  const registry = new Registry();
1365
1577
  const flagApps = appsFromFlags(flags);
1366
1578
  const nonInteractive = flags.yes === true || flagApps !== null;
@@ -1461,6 +1673,27 @@ async function runCreate(flags) {
1461
1673
  installed = false;
1462
1674
  }
1463
1675
  }
1676
+ let gitInitialized = false;
1677
+ const gitAvailable = hasCommand("git");
1678
+ const alreadyRepo = gitAvailable && isInsideGitRepo(choices.outDir);
1679
+ let wantGit;
1680
+ if (flags.git !== void 0) {
1681
+ wantGit = flags.git;
1682
+ } else if (nonInteractive) {
1683
+ wantGit = false;
1684
+ } else {
1685
+ const ans = await confirm3({ message: "Initialize a git repository?" });
1686
+ wantGit = !isCancel3(ans) && ans === true;
1687
+ }
1688
+ if (wantGit && gitAvailable && !alreadyRepo) {
1689
+ gitInitialized = initGit(choices.outDir);
1690
+ if (gitInitialized) log2.success("Initialized a git repository (branch main, initial commit).");
1691
+ else log2.warn("Couldn't create the initial git commit \u2014 the repo was left uninitialized.");
1692
+ } else if (wantGit && !gitAvailable) {
1693
+ log2.warn("git isn't installed \u2014 skipped repository initialization.");
1694
+ } else if (wantGit && alreadyRepo) {
1695
+ log2.info("Target is already inside a git repository \u2014 skipped `git init`.");
1696
+ }
1464
1697
  const rel = basename(choices.outDir);
1465
1698
  const hasDocker = resolved.modules.includes("docker");
1466
1699
  const steps = [`cd ${rel}`];
@@ -1471,12 +1704,12 @@ async function runCreate(flags) {
1471
1704
  }
1472
1705
  if (choices.apps.includes("web")) steps.push("npm run web");
1473
1706
  if (choices.apps.includes("mobile")) steps.push("npm run mobile");
1474
- note(
1475
- steps.join("\n") + "\n\n" + pc2.dim("These work on macOS, Linux & Windows. On macOS/Linux, `make <task>` works too."),
1707
+ note2(
1708
+ steps.join("\n") + "\n\n" + pc3.dim("These work on macOS, Linux & Windows. On macOS/Linux, `make <task>` works too."),
1476
1709
  "Next steps"
1477
1710
  );
1478
- if (result.notes.length) note(result.notes.join("\n"), "Notes");
1479
- outro(pc2.green("Done."));
1711
+ if (result.notes.length) note2(result.notes.join("\n"), "Notes");
1712
+ outro(pc3.green("Done."));
1480
1713
  }
1481
1714
 
1482
1715
  export {
@@ -1496,4 +1729,4 @@ export {
1496
1729
  validateApps,
1497
1730
  runCreate
1498
1731
  };
1499
- //# sourceMappingURL=chunk-ANKGMR2Z.js.map
1732
+ //# sourceMappingURL=chunk-I2IJROBO.js.map
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCreate
4
- } from "./chunk-ANKGMR2Z.js";
4
+ } from "./chunk-I2IJROBO.js";
5
5
 
6
6
  // src/create-bin.ts
7
7
  import { Command } from "commander";
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  selectedTargets,
16
16
  validateApps,
17
17
  writeProjectManifest
18
- } from "./chunk-ANKGMR2Z.js";
18
+ } from "./chunk-I2IJROBO.js";
19
19
 
20
20
  // src/index.ts
21
21
  import { Command } from "commander";
@@ -178,7 +178,7 @@ async function runDoctor(flags) {
178
178
  function buildProgram() {
179
179
  const program = new Command();
180
180
  program.name("partweave").description("A modular full-stack scaffolder \u2014 pick parts, generate only that code.").version("0.1.0");
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) => {
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)").option("--git", "initialize a git repo + initial commit (default: ask, off with --yes)").option("--no-git", "skip git initialization").action(async (name, opts) => {
182
182
  const flags = { ...opts, name: name ?? opts.name };
183
183
  await runCreate(flags);
184
184
  });
@@ -16,7 +16,7 @@
16
16
  }
17
17
  },
18
18
  "notes": [
19
- "PostgreSQL: set DATABASE_URL in .env (already added to .env.example).",
19
+ "PostgreSQL: DATABASE_URL lives in apps/server/.env (created for you, with a matching .env.example).",
20
20
  "Need a local database? Include the 'docker' component and run `make db-up`."
21
21
  ]
22
22
  }
@@ -14,6 +14,6 @@
14
14
  },
15
15
  "notes": [
16
16
  "Local DB: `make db-up` (docker compose). Server image: `docker build apps/server`.",
17
- "Postgres creds/port come from .env (POSTGRES_USER/PASSWORD/DB/PORT); keep them in sync with DATABASE_URL."
17
+ "Postgres creds/port come from the root `.env` (POSTGRES_USER/PASSWORD/DB/PORT); keep them in sync with DATABASE_URL in apps/server/.env."
18
18
  ]
19
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partweave",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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": "Apache-2.0",