@jxsuite/create 1.3.5 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/generate.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Shared project generation logic. Used by both the CLI scaffolder and the Studio server endpoint. */
2
2
 
3
- import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { chmod, cp, lstat, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
4
  import { adapterNeedsWrangler, buildWranglerJsonc } from "./scaffold";
5
5
  import { basename, join, relative, sep } from "node:path";
6
6
 
@@ -63,6 +63,80 @@ const STARTER_EXCLUDE = new Set([
63
63
  "images.json",
64
64
  ]);
65
65
 
66
+ /**
67
+ * Add owner-write to every file and directory in a freshly copied tree.
68
+ *
69
+ * `fs.cp` reproduces the SOURCE's permission bits on the destination — correct for a backup, wrong
70
+ * for a scaffolder. When the templates ship from a read-only store the new project lands read-only:
71
+ * every path under /nix/store is 444/555 by construction, and a root-owned or content-addressed
72
+ * install can be too. The generator then fails on its own next write (the project.json re-stamp,
73
+ * EACCES), and had it not, the user's `bun install`, `jx build` and every later edit would fail the
74
+ * same way — the destination directory itself is writable only because `mkdir` created it.
75
+ *
76
+ * Bits are OR-ed rather than assigned, so a template that deliberately marks a file executable
77
+ * keeps it; only owner-write is forced on, plus owner-search on directories so the walk can enter
78
+ * them. Symlinks are skipped: `chmod` follows them, and a link pointing out of the tree must not
79
+ * have its target rewritten. A link's target inside the tree is normalized when the walk reaches it
80
+ * directly.
81
+ *
82
+ * @param {string} dir — absolute path to a directory that is already writable
83
+ */
84
+ async function makeWritable(dir: string): Promise<void> {
85
+ const entries = await readdir(dir, { withFileTypes: true });
86
+ await Promise.all(
87
+ entries.map(async (entry) => {
88
+ if (entry.isSymbolicLink()) {
89
+ return;
90
+ }
91
+ const full = join(dir, entry.name);
92
+ if (entry.isDirectory()) {
93
+ const { mode } = await lstat(full);
94
+ // Widened before recursing: a directory lacking owner-search cannot be read into.
95
+ // oxlint-disable-next-line no-bitwise -- st_mode is a bitfield; OR-ing is how a bit is added
96
+ await chmod(full, mode | 0o700);
97
+ await makeWritable(full);
98
+ return;
99
+ }
100
+ if (entry.isFile()) {
101
+ const { mode } = await lstat(full);
102
+ // oxlint-disable-next-line no-bitwise -- st_mode is a bitfield; OR-ing is how a bit is added
103
+ await chmod(full, mode | 0o200);
104
+ }
105
+ }),
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Remove the debris of a scaffold that threw partway through.
111
+ *
112
+ * The safety rule is that this may only delete what the generator itself wrote. `destPath` is
113
+ * removed outright ONLY when the generator created it; when the caller pointed at a directory that
114
+ * already existed, {@link generateProject} has just proved it was empty, so everything inside is
115
+ * ours and the directory the user made is left standing. Nothing that predates the call is
116
+ * reachable either way.
117
+ *
118
+ * @param {string} destPath — absolute path to the project directory
119
+ * @param {boolean} destExisted — whether destPath existed before the generator ran
120
+ */
121
+ async function discardPartialScaffold(destPath: string, destExisted: boolean): Promise<void> {
122
+ try {
123
+ // The tree is very likely read-only: that is the failure this most often follows.
124
+ // Unlinking an entry needs write permission on the directory that holds it.
125
+ await makeWritable(destPath);
126
+ if (!destExisted) {
127
+ await rm(destPath, { force: true, recursive: true });
128
+ return;
129
+ }
130
+ const entries = await readdir(destPath);
131
+ await Promise.all(
132
+ entries.map(async (entry) => rm(join(destPath, entry), { force: true, recursive: true })),
133
+ );
134
+ } catch {
135
+ // Best-effort: the scaffold failure is the one worth reporting.
136
+ // A cleanup that cannot finish must not replace it with a second, less useful error.
137
+ }
138
+ }
139
+
66
140
  /**
67
141
  * Generate a new Jx project at the given path.
68
142
  *
@@ -70,6 +144,34 @@ const STARTER_EXCLUDE = new Set([
70
144
  * @param {ProjectOptions} opts
71
145
  */
72
146
  export async function generateProject(destPath: string, opts: ProjectOptions) {
147
+ const destExisted = existsSync(destPath);
148
+ if (destExisted) {
149
+ const { readdirSync } = await import("node:fs");
150
+ if (readdirSync(destPath).length > 0) {
151
+ throw new Error(`Directory "${destPath}" is not empty`);
152
+ }
153
+ }
154
+
155
+ await mkdir(destPath, { recursive: true });
156
+
157
+ try {
158
+ await populateProject(destPath, opts);
159
+ } catch (error) {
160
+ // A half-written project is worse than no project: it is what turns the user's retry into
161
+ // `Directory "..." is not empty`, an error that describes the debris rather than the failure.
162
+ await discardPartialScaffold(destPath, destExisted);
163
+ throw error;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Write the project itself. Split out of {@link generateProject} so the destination guard, the
169
+ * directory creation and the rollback surround every write below without indenting them.
170
+ *
171
+ * @param {string} destPath — absolute path to the (existing, empty) project directory
172
+ * @param {ProjectOptions} opts
173
+ */
174
+ async function populateProject(destPath: string, opts: ProjectOptions) {
73
175
  const {
74
176
  name,
75
177
  description = "",
@@ -80,15 +182,6 @@ export async function generateProject(destPath: string, opts: ProjectOptions) {
80
182
  design,
81
183
  } = opts;
82
184
 
83
- if (existsSync(destPath)) {
84
- const { readdirSync } = await import("node:fs");
85
- if (readdirSync(destPath).length > 0) {
86
- throw new Error(`Directory "${destPath}" is not empty`);
87
- }
88
- }
89
-
90
- await mkdir(destPath, { recursive: true });
91
-
92
185
  if (starter && starter !== "blank") {
93
186
  await scaffoldFromStarter(destPath, starter, {
94
187
  adapter,
@@ -125,6 +218,10 @@ export async function generateProject(destPath: string, opts: ProjectOptions) {
125
218
 
126
219
  await Promise.all(writes);
127
220
 
221
+ // The .gitignore, layouts/ and pages/ above are copies too, carrying the template's modes.
222
+ // The overlay below would otherwise fail to `force` over a file that came across read-only.
223
+ await makeWritable(destPath);
224
+
128
225
  // The mobile-app variant overlays the shared skeleton with its app-shell layout and home page.
129
226
  // Sequenced after the base copies so the overlay files win.
130
227
  if (template === "mobile-app") {
@@ -132,6 +229,7 @@ export async function generateProject(destPath: string, opts: ProjectOptions) {
132
229
  force: true,
133
230
  recursive: true,
134
231
  });
232
+ await makeWritable(destPath);
135
233
  }
136
234
 
137
235
  if (design?.logo) {
@@ -175,6 +273,9 @@ async function scaffoldFromStarter(
175
273
  },
176
274
  });
177
275
 
276
+ // Before the re-stamp below, which writes straight back into the tree that was just copied.
277
+ await makeWritable(destPath);
278
+
178
279
  // Re-stamp project.json with the new project's identity, keeping the starter's design tokens,
179
280
  // Content types, image pipeline, and head.
180
281
  const projectPath = join(destPath, "project.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/create",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "description": "Scaffold a new Jx project",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,6 +37,6 @@
37
37
  "test:coverage": "bun test --isolate --coverage"
38
38
  },
39
39
  "dependencies": {
40
- "@jxsuite/starters": "^1.6.2"
40
+ "@jxsuite/starters": "^1.6.3"
41
41
  }
42
42
  }
@@ -1,6 +1,6 @@
1
1
  {
2
- "parser": "^1.5.4",
3
- "compiler": "^2.0.5",
4
- "runtime": "^2.0.4",
5
- "server": "^2.2.5"
2
+ "parser": "^1.5.5",
3
+ "compiler": "^2.0.7",
4
+ "runtime": "^2.0.5",
5
+ "server": "^2.2.7"
6
6
  }