@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 +111 -10
- package/package.json +2 -2
- package/template-versions.json +4 -4
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.
|
|
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.
|
|
40
|
+
"@jxsuite/starters": "^1.6.3"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/template-versions.json
CHANGED