@m4l-jweb/build 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.
package/src/index.mjs CHANGED
@@ -22,8 +22,6 @@ const require = createRequire(import.meta.url);
22
22
  const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
23
  const templates = path.join(pkgDir, "templates");
24
24
 
25
- const UI_NAME = "ui.html";
26
-
27
25
  /* ------------------------------------------------------------------ *
28
26
  * Step 1: the wrapper
29
27
  * ------------------------------------------------------------------ */
@@ -38,70 +36,70 @@ const UI_NAME = "ui.html";
38
36
  * order.
39
37
  */
40
38
  export function buildWrapper(root) {
41
- const { sources, types } = require("@m4l-jweb/wrapper/sources");
42
- const deviceExt = path.join(root, "wrapper", "device.ts");
43
- const files = [...sources, ...(existsSync(deviceExt) ? [deviceExt] : [])];
44
-
45
- const outDir = path.join(root, "dist", "wrapper");
46
- const tmp = path.join(root, "dist", ".wrapper-tsc");
47
- rmSync(tmp, { recursive: true, force: true });
48
- mkdirSync(tmp, { recursive: true });
49
- mkdirSync(outDir, { recursive: true });
50
-
51
- // Copy every source next to each other FIRST, then compile in place.
52
- //
53
- // tsc derives its output layout from the common root of its inputs. The
54
- // packaged sources live in node_modules and the device's own device.ts lives
55
- // in the repo, so that common root can be some ancestor of both - and the
56
- // outputs land in a mirrored directory tree instead of flat. Staging them in
57
- // one directory makes the output names predictable, which is what lets us
58
- // concatenate them in order below.
59
- const staged = files.map((f, i) => {
60
- // Prefix with the index: order is the contract (core must precede the rest),
61
- // and two sources could share a basename.
62
- const dest = path.join(tmp, `${String(i).padStart(2, "0")}-${path.basename(f)}`);
63
- writeFileSync(dest, readFileSync(f, "utf8"));
64
- return dest;
65
- });
66
- const stagedTypes = path.join(tmp, path.basename(types));
67
- writeFileSync(stagedTypes, readFileSync(types, "utf8"));
68
-
69
- // The ES5 target is a build gate, not a style preference. `module: "none"`
70
- // forbids imports, which is exactly the [js] constraint.
71
- const tsconfig = path.join(tmp, "tsconfig.json");
72
- writeFileSync(
73
- tsconfig,
74
- JSON.stringify({
75
- compilerOptions: {
76
- target: "ES5",
77
- lib: ["ES5"],
78
- module: "none",
79
- outDir: tmp,
80
- strict: true,
81
- // At [js] global scope `this` IS the jsthis object - that is how
82
- // `this.patcher.filepath` works.
83
- noImplicitThis: false,
84
- noImplicitAny: false,
85
- skipLibCheck: true,
86
- types: [],
87
- },
88
- files: [stagedTypes, ...staged],
89
- }),
90
- );
91
-
92
- execFileSync(process.execPath, [require.resolve("typescript/bin/tsc"), "-p", tsconfig], { stdio: "inherit" });
93
-
94
- // Concatenate the emitted scripts in source order: core's lifecycle first, the
95
- // device's own handlers last.
96
- const js = staged.map((f) => readFileSync(f.replace(/\.ts$/, ".js"), "utf8")).join("\n");
97
- assertES5(js, "wrapper");
98
-
99
- const out = path.join(outDir, "wrapper.js");
100
- writeFileSync(out, js);
101
- rmSync(tmp, { recursive: true, force: true });
102
-
103
- console.log(`m4l-jweb: wrapper.js (${js.length} bytes, ES5 verified, from ${files.length} sources)`);
104
- return out;
39
+ const { sources, types } = require("@m4l-jweb/wrapper/sources");
40
+ const deviceExt = path.join(root, "wrapper", "device.ts");
41
+ const files = [...sources, ...(existsSync(deviceExt) ? [deviceExt] : [])];
42
+
43
+ const outDir = path.join(root, "dist", "wrapper");
44
+ const tmp = path.join(root, "dist", ".wrapper-tsc");
45
+ rmSync(tmp, { recursive: true, force: true });
46
+ mkdirSync(tmp, { recursive: true });
47
+ mkdirSync(outDir, { recursive: true });
48
+
49
+ // Copy every source next to each other FIRST, then compile in place.
50
+ //
51
+ // tsc derives its output layout from the common root of its inputs. The
52
+ // packaged sources live in node_modules and the device's own device.ts lives
53
+ // in the repo, so that common root can be some ancestor of both - and the
54
+ // outputs land in a mirrored directory tree instead of flat. Staging them in
55
+ // one directory makes the output names predictable, which is what lets us
56
+ // concatenate them in order below.
57
+ const staged = files.map((f, i) => {
58
+ // Prefix with the index: order is the contract (core must precede the rest),
59
+ // and two sources could share a basename.
60
+ const dest = path.join(tmp, `${String(i).padStart(2, "0")}-${path.basename(f)}`);
61
+ writeFileSync(dest, readFileSync(f, "utf8"));
62
+ return dest;
63
+ });
64
+ const stagedTypes = path.join(tmp, path.basename(types));
65
+ writeFileSync(stagedTypes, readFileSync(types, "utf8"));
66
+
67
+ // The ES5 target is a build gate, not a style preference. `module: "none"`
68
+ // forbids imports, which is exactly the [js] constraint.
69
+ const tsconfig = path.join(tmp, "tsconfig.json");
70
+ writeFileSync(
71
+ tsconfig,
72
+ JSON.stringify({
73
+ compilerOptions: {
74
+ target: "ES5",
75
+ lib: ["ES5"],
76
+ module: "none",
77
+ outDir: tmp,
78
+ strict: true,
79
+ // At [js] global scope `this` IS the jsthis object - that is how
80
+ // `this.patcher.filepath` works.
81
+ noImplicitThis: false,
82
+ noImplicitAny: false,
83
+ skipLibCheck: true,
84
+ types: [],
85
+ },
86
+ files: [stagedTypes, ...staged],
87
+ }),
88
+ );
89
+
90
+ execFileSync(process.execPath, [require.resolve("typescript/bin/tsc"), "-p", tsconfig], { stdio: "inherit" });
91
+
92
+ // Concatenate the emitted scripts in source order: core's lifecycle first, the
93
+ // device's own handlers last.
94
+ const js = staged.map((f) => readFileSync(f.replace(/\.ts$/, ".js"), "utf8")).join("\n");
95
+ assertES5(js, "wrapper");
96
+
97
+ const out = path.join(outDir, "wrapper.js");
98
+ writeFileSync(out, js);
99
+ rmSync(tmp, { recursive: true, force: true });
100
+
101
+ console.log(`m4l-jweb: wrapper.js (${js.length} bytes, ES5 verified, from ${files.length} sources)`);
102
+ return out;
105
103
  }
106
104
 
107
105
  /* ------------------------------------------------------------------ *
@@ -109,16 +107,16 @@ export function buildWrapper(root) {
109
107
  * ------------------------------------------------------------------ */
110
108
 
111
109
  async function readManifest(root) {
112
- const p = path.join(root, "patcher", "devices.mjs");
113
- if (!existsSync(p)) throw new Error("patcher/devices.mjs not found - a device repo needs a manifest");
114
- return (await import(pathToFileURL(p).href)).default;
110
+ const p = path.join(root, "patcher", "devices.mjs");
111
+ if (!existsSync(p)) throw new Error("patcher/devices.mjs not found - a device repo needs a manifest");
112
+ return (await import(pathToFileURL(p).href)).default;
115
113
  }
116
114
 
117
115
  /** patcher/base.json in the device repo wins; otherwise the packaged template. */
118
116
  function readBase(root) {
119
- const local = path.join(root, "patcher", "base.json");
120
- const src = existsSync(local) ? local : path.join(templates, "base.json");
121
- return JSON.parse(readFileSync(src, "utf8"));
117
+ const local = path.join(root, "patcher", "base.json");
118
+ const src = existsSync(local) ? local : path.join(templates, "base.json");
119
+ return JSON.parse(readFileSync(src, "utf8"));
122
120
  }
123
121
 
124
122
  /**
@@ -133,52 +131,52 @@ function readBase(root) {
133
131
  * in the library.
134
132
  */
135
133
  async function loadDeviceChains(root) {
136
- const p = path.join(root, "patcher", "chains.mjs");
137
- if (!existsSync(p)) return;
138
- await import(pathToFileURL(p).href);
139
- console.log("m4l-jweb: loaded device chains from patcher/chains.mjs");
134
+ const p = path.join(root, "patcher", "chains.mjs");
135
+ if (!existsSync(p)) return;
136
+ await import(pathToFileURL(p).href);
137
+ console.log("m4l-jweb: loaded device chains from patcher/chains.mjs");
140
138
  }
141
139
 
142
140
  export async function generatePatchers(root) {
143
- const devices = await readManifest(root);
144
- const base = readBase(root);
145
- await loadDeviceChains(root);
146
- const outDir = path.join(root, "dist", "patchers");
147
- mkdirSync(outDir, { recursive: true });
148
-
149
- for (const d of devices) {
150
- const amxdtype = AMXD_TYPES[d.type];
151
- if (!amxdtype) throw new Error(`unknown type "${d.type}" for device "${d.name}" (midi | audio | instrument)`);
152
-
153
- const p = structuredClone(base);
154
- const { boxes, lines } = p.patcher;
155
- p.patcher.project.amxdtype = amxdtype;
156
- resetLayout();
157
-
158
- // The wrapper is mode-switched by its object-box argument. `mode` defaults
159
- // to the device type, but they are not always the same thing: a sample
160
- // player can be an audio-effect device ("type") that the wrapper must treat
161
- // as a sampler ("mode").
162
- //
163
- // jsarguments[0] is the SCRIPT NAME, so the mode lands at jsarguments[1].
164
- const mode = d.mode ?? d.type;
165
- boxes.find((b) => b.box.id === "obj-js").box.text = `js wrapper.js ${mode}`;
166
-
167
- const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
168
-
169
- for (const name of d.chains ?? []) {
170
- const chain = CHAINS[name];
171
- if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
172
- chain({ boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d });
173
- }
174
-
175
- // Parameters feed the UI: a knob move arrives as just another inlet message.
176
- addParameters(boxes, lines, d.parameters ?? [], "obj-jweb");
177
-
178
- writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
179
- console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"})`);
180
- }
181
- return devices;
141
+ const devices = await readManifest(root);
142
+ const base = readBase(root);
143
+ await loadDeviceChains(root);
144
+ const outDir = path.join(root, "dist", "patchers");
145
+ mkdirSync(outDir, { recursive: true });
146
+
147
+ for (const d of devices) {
148
+ const amxdtype = AMXD_TYPES[d.type];
149
+ if (!amxdtype) throw new Error(`unknown type "${d.type}" for device "${d.name}" (midi | audio | instrument)`);
150
+
151
+ const p = structuredClone(base);
152
+ const { boxes, lines } = p.patcher;
153
+ p.patcher.project.amxdtype = amxdtype;
154
+ resetLayout();
155
+
156
+ // The wrapper is mode-switched by its object-box argument. `mode` defaults
157
+ // to the device type, but they are not always the same thing: a sample
158
+ // player can be an audio-effect device ("type") that the wrapper must treat
159
+ // as a sampler ("mode").
160
+ //
161
+ // jsarguments[0] is the SCRIPT NAME, so the mode lands at jsarguments[1].
162
+ const mode = d.mode ?? d.type;
163
+ boxes.find((b) => b.box.id === "obj-js").box.text = `js wrapper.js ${mode}`;
164
+
165
+ const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
166
+
167
+ for (const name of d.chains ?? []) {
168
+ const chain = CHAINS[name];
169
+ if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
170
+ chain({ boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d });
171
+ }
172
+
173
+ // Parameters feed the UI: a knob move arrives as just another inlet message.
174
+ addParameters(boxes, lines, d.parameters ?? [], "obj-jweb");
175
+
176
+ writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
177
+ console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"})`);
178
+ }
179
+ return devices;
182
180
  }
183
181
 
184
182
  /* ------------------------------------------------------------------ *
@@ -194,99 +192,119 @@ export async function generatePatchers(root) {
194
192
  * requirement.
195
193
  */
196
194
  export async function packageDevices(root) {
197
- const dist = path.join(root, "dist");
198
- const { name, version } = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
199
- const devices = await readManifest(root);
200
-
201
- const outDir = path.join(dist, name);
202
- mkdirSync(outDir, { recursive: true });
203
-
204
- // vite emits dist/index.html (everything inlined by vite-plugin-singlefile).
205
- const uiPath = path.join(outDir, UI_NAME);
206
- const viteOut = path.join(dist, "index.html");
207
- if (existsSync(viteOut)) await rename(viteOut, uiPath);
208
- if (!existsSync(uiPath)) throw new Error(`no UI at ${uiPath} - run \`vite build\` first`);
209
-
210
- const uiHtml = readFileSync(uiPath);
211
- const wrapperJs = readFileSync(path.join(dist, "wrapper", "wrapper.js"), "utf8");
212
-
213
- // The build stamp is what makes a stale install visible: the wrapper posts it
214
- // and the UI renders it. Live embeds a copy of the device in the set, so an
215
- // instance already on a track does NOT update when you reinstall.
216
- const stamp = `${version} ${new Date().toISOString()}`;
217
- const banner = `var BUILD_STAMP = ${JSON.stringify(stamp)};\n`;
218
-
219
- for (const d of devices) {
220
- const deviceName = `${d.name}.amxd`;
221
-
222
- /**
223
- * Payloads ride inside wrapper.js as base64 and are written to real files
224
- * next to the .amxd on first load, because Chromium and any external
225
- * process are blind to Max's frozen virtual filesystem. The UI is always
226
- * one; a device can declare more (`payloads: ["dist/foo.cjs"]`).
227
- */
228
- let wrapperData = banner + wrapperJs + payloadJs("UI_PAYLOAD", UI_NAME, uiHtml);
229
- const payloads = (d.payloads ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) }));
230
- if (payloads.length) wrapperData += extraPayloadsJs(payloads);
231
-
232
- const amxd = buildAmxd({
233
- patcherJson: readFileSync(path.join(dist, "patchers", `${d.name}.json`), "utf8"),
234
- wrapperJs: wrapperData,
235
- deviceName,
236
- // Frozen dependencies: readable by Max-native objects only (a poly~
237
- // voice patcher, say), which is exactly why they can stay frozen.
238
- extras: (d.extraFiles ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) })),
239
- });
240
- writeFileSync(path.join(outDir, deviceName), amxd);
241
- console.log(`m4l-jweb: ${deviceName} (${d.type}, ${amxd.length} bytes)`);
242
- }
243
-
244
- await copyFile(path.join(dist, "wrapper", "wrapper.js"), path.join(outDir, "wrapper.js"));
245
-
246
- /**
247
- * Loose files sit NEXT TO the .amxd in the installed folder, as real files.
248
- *
249
- * Needed when a Max object resolves a filename when it INSTANTIATES - before
250
- * the wrapper has run and before it could have extracted anything. Such an
251
- * object cannot be repointed at runtime, so the file has to be on disk under
252
- * exactly the name the object was created with. The embedded payload of the
253
- * same file is then only a fallback for a bare .amxd copied on its own.
254
- */
255
- const loose = [...new Set(devices.flatMap((d) => d.looseFiles ?? []))];
256
- for (const f of loose) {
257
- await copyFile(path.join(root, f), path.join(outDir, path.basename(f)));
258
- console.log(`m4l-jweb: ${path.basename(f)} -> dist/${name}/ (loose)`);
259
- }
260
-
261
- // Installers go next to the devices so `dist/install-*.ps1` just works.
262
- const installers = ["install-windows.ps1", "install-mac.sh"];
263
- for (const f of installers) await copyFile(path.join(templates, f), path.join(dist, f));
264
-
265
- const zipPath = path.join(dist, `${name}.zip`);
266
- await new Promise((resolve, reject) => {
267
- const output = createWriteStream(zipPath);
268
- const archive = archiver("zip", { zlib: { level: 9 } });
269
- output.on("close", resolve);
270
- archive.on("error", reject);
271
- archive.pipe(output);
272
- const files = [...devices.map((d) => `${d.name}.amxd`), ...loose.map((f) => path.basename(f)), "wrapper.js", UI_NAME];
273
- for (const f of files) {
274
- archive.append(createReadStream(path.join(outDir, f)), { name: `${name}/${f}` });
275
- }
276
- for (const f of installers) {
277
- archive.file(path.join(templates, f), { name: f, mode: 0o755 });
278
- }
279
- archive.finalize();
280
- });
281
-
282
- const { size } = await stat(zipPath);
283
- console.log(`m4l-jweb: dist/${name}.zip (${size} bytes)`);
195
+ const dist = path.join(root, "dist");
196
+ const { name, version } = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
197
+ const devices = await readManifest(root);
198
+
199
+ const outDir = path.join(dist, name);
200
+ mkdirSync(outDir, { recursive: true });
201
+
202
+ const wrapperJs = readFileSync(path.join(dist, "wrapper", "wrapper.js"), "utf8");
203
+
204
+ // The build stamp is what makes a stale install visible: the wrapper posts it
205
+ // and the UI renders it. Live embeds a copy of the device in the set, so an
206
+ // instance already on a track does NOT update when you reinstall.
207
+ const stamp = `${version} ${new Date().toISOString()}`;
208
+ const banner = `var BUILD_STAMP = ${JSON.stringify(stamp)};\n`;
209
+
210
+ for (const d of devices) {
211
+ const deviceName = `${d.name}.amxd`;
212
+
213
+ /**
214
+ * Each device embeds its OWN UI bundle, from dist/ui/<ui ?? name>/index.html
215
+ * (see scripts/build-ui.mjs). A device ships what it is, not its siblings'
216
+ * code.
217
+ *
218
+ * The payload NAME is per-device too - `<device>.html`, not a shared
219
+ * `ui.html`. Every device in a repo extracts its payload into the SAME
220
+ * folder (next to the .amxd), so one shared name would mean two devices
221
+ * overwriting each other's UI on every load, each one convinced the file on
222
+ * disk was stale. The symptom would be a device showing its sibling's
223
+ * interface.
224
+ */
225
+ const uiName = `${d.name}.html`;
226
+ const uiSrc = path.join(dist, "ui", d.ui ?? d.name, "index.html");
227
+ const legacy = path.join(dist, "index.html"); // single-UI repos (the starter template)
228
+ const uiFrom = existsSync(uiSrc) ? uiSrc : legacy;
229
+ if (!existsSync(uiFrom)) {
230
+ throw new Error(`no UI for "${d.name}" at ${uiSrc} - run \`pnpm build\` (scripts/build-ui.mjs) first`);
231
+ }
232
+ const uiHtml = readFileSync(uiFrom);
233
+ await copyFile(uiFrom, path.join(outDir, uiName));
234
+
235
+ /**
236
+ * Payloads ride inside wrapper.js as base64 and are written to real files
237
+ * next to the .amxd on first load, because Chromium and any external
238
+ * process are blind to Max's frozen virtual filesystem. The UI is always
239
+ * one; a device can declare more (`payloads: ["dist/foo.cjs"]`).
240
+ */
241
+ let wrapperData = banner + wrapperJs + payloadJs("UI_PAYLOAD", uiName, uiHtml);
242
+ const payloads = (d.payloads ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) }));
243
+ if (payloads.length) wrapperData += extraPayloadsJs(payloads);
244
+
245
+ const amxd = buildAmxd({
246
+ patcherJson: readFileSync(path.join(dist, "patchers", `${d.name}.json`), "utf8"),
247
+ wrapperJs: wrapperData,
248
+ deviceName,
249
+ // Frozen dependencies: readable by Max-native objects only (a poly~
250
+ // voice patcher, say), which is exactly why they can stay frozen.
251
+ extras: (d.extraFiles ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) })),
252
+ });
253
+ writeFileSync(path.join(outDir, deviceName), amxd);
254
+ console.log(`m4l-jweb: ${deviceName} (${d.type}, ${amxd.length} bytes)`);
255
+ }
256
+
257
+ await copyFile(path.join(dist, "wrapper", "wrapper.js"), path.join(outDir, "wrapper.js"));
258
+
259
+ /**
260
+ * Loose files sit NEXT TO the .amxd in the installed folder, as real files.
261
+ *
262
+ * Needed when a Max object resolves a filename when it INSTANTIATES - before
263
+ * the wrapper has run and before it could have extracted anything. Such an
264
+ * object cannot be repointed at runtime, so the file has to be on disk under
265
+ * exactly the name the object was created with. The embedded payload of the
266
+ * same file is then only a fallback for a bare .amxd copied on its own.
267
+ */
268
+ const loose = [...new Set(devices.flatMap((d) => d.looseFiles ?? []))];
269
+ for (const f of loose) {
270
+ await copyFile(path.join(root, f), path.join(outDir, path.basename(f)));
271
+ console.log(`m4l-jweb: ${path.basename(f)} -> dist/${name}/ (loose)`);
272
+ }
273
+
274
+ // Installers go next to the devices so `dist/install-*.ps1` just works.
275
+ const installers = ["install-windows.ps1", "install-mac.sh"];
276
+ for (const f of installers) await copyFile(path.join(templates, f), path.join(dist, f));
277
+
278
+ const zipPath = path.join(dist, `${name}.zip`);
279
+ await new Promise((resolve, reject) => {
280
+ const output = createWriteStream(zipPath);
281
+ const archive = archiver("zip", { zlib: { level: 9 } });
282
+ output.on("close", resolve);
283
+ archive.on("error", reject);
284
+ archive.pipe(output);
285
+ const files = [
286
+ ...devices.map((d) => `${d.name}.amxd`),
287
+ ...devices.map((d) => `${d.name}.html`), // each device's own UI, for inspection
288
+ ...loose.map((f) => path.basename(f)),
289
+ "wrapper.js",
290
+ ];
291
+ for (const f of files) {
292
+ archive.append(createReadStream(path.join(outDir, f)), { name: `${name}/${f}` });
293
+ }
294
+ for (const f of installers) {
295
+ archive.file(path.join(templates, f), { name: f, mode: 0o755 });
296
+ }
297
+ archive.finalize();
298
+ });
299
+
300
+ const { size } = await stat(zipPath);
301
+ console.log(`m4l-jweb: dist/${name}.zip (${size} bytes)`);
284
302
  }
285
303
 
286
304
  export async function buildAll(root) {
287
- buildWrapper(root);
288
- await generatePatchers(root);
289
- await packageDevices(root);
305
+ buildWrapper(root);
306
+ await generatePatchers(root);
307
+ await packageDevices(root);
290
308
  }
291
309
 
292
310
  /* ------------------------------------------------------------------ *
@@ -299,27 +317,27 @@ export async function buildAll(root) {
299
317
  * Live has no Linux build, so there is nothing to install there.
300
318
  * ------------------------------------------------------------------ */
301
319
  export async function installDevices(root) {
302
- const { name } = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
303
-
304
- if (!existsSync(path.join(root, "dist", name))) {
305
- throw new Error(`nothing built at dist/${name} - run \`pnpm build\` first`);
306
- }
307
-
308
- // The packaged scripts are the real implementation - they have to read Live's
309
- // own config files to locate the User Library. Pass the device name and the
310
- // built folder explicitly, since the script does not live in the repo.
311
- const src = path.join(root, "dist", name);
312
- const runners = {
313
- win32: [
314
- "powershell",
315
- ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(templates, "install-windows.ps1"), "-DeviceName", name, "-Src", src],
316
- ],
317
- darwin: ["bash", [path.join(templates, "install-mac.sh"), name, src]],
318
- };
319
- const runner = runners[process.platform];
320
- if (!runner) {
321
- throw new Error(`no installer for ${process.platform} - Ableton Live runs on macOS and Windows only`);
322
- }
323
-
324
- execFileSync(runner[0], runner[1], { stdio: "inherit", cwd: root });
320
+ const { name } = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
321
+
322
+ if (!existsSync(path.join(root, "dist", name))) {
323
+ throw new Error(`nothing built at dist/${name} - run \`pnpm build\` first`);
324
+ }
325
+
326
+ // The packaged scripts are the real implementation - they have to read Live's
327
+ // own config files to locate the User Library. Pass the device name and the
328
+ // built folder explicitly, since the script does not live in the repo.
329
+ const src = path.join(root, "dist", name);
330
+ const runners = {
331
+ win32: [
332
+ "powershell",
333
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(templates, "install-windows.ps1"), "-DeviceName", name, "-Src", src],
334
+ ],
335
+ darwin: ["bash", [path.join(templates, "install-mac.sh"), name, src]],
336
+ };
337
+ const runner = runners[process.platform];
338
+ if (!runner) {
339
+ throw new Error(`no installer for ${process.platform} - Ableton Live runs on macOS and Windows only`);
340
+ }
341
+
342
+ execFileSync(runner[0], runner[1], { stdio: "inherit", cwd: root });
325
343
  }
package/src/init.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * init.mjs - `m4l-jweb init [dir] [--name <name>]`: scaffold a new device repo.
3
3
  *
4
- * The template lives in templates/starter/ and is not a hand-maintained copy:
5
- * it mirrors this repo's own root app (src/app/, patcher/devices.mjs, the
6
- * config files), which is itself a working hello-world device built on
7
- * @m4l-jweb/bridge and @m4l-jweb/build. Keeping the template that close to a
8
- * real, CI-built app is what keeps it from drifting out of sync with the
9
- * library - change the shape of a real device, then port the same change
10
- * into templates/starter/ in the same commit.
4
+ * The template lives in templates/starter/ and mirrors this repo's own layout:
5
+ * the same scripts/, vite.config.ts, tsconfig and src/app/shared/, with one
6
+ * device instead of three.
7
+ *
8
+ * Those shared files are checked for drift by tests/starter.test.mjs, which
9
+ * compares them byte-for-byte against the root repo's. The template used to be
10
+ * kept in sync by hand and by good intentions, and it fell behind.
11
+ *
12
+ * `{{name}}` is substituted in file CONTENTS and in PATHS - the device's app
13
+ * lives at src/app/{{name}}/, so the folder is named after the device too.
11
14
  */
12
15
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
13
16
  import path from "node:path";
@@ -17,36 +20,38 @@ const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
20
  const starter = path.join(pkgDir, "templates", "starter");
18
21
 
19
22
  function walk(dir) {
20
- const out = [];
21
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
22
- const p = path.join(dir, entry.name);
23
- if (entry.isDirectory()) out.push(...walk(p));
24
- else out.push(p);
25
- }
26
- return out;
23
+ const out = [];
24
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
25
+ const p = path.join(dir, entry.name);
26
+ if (entry.isDirectory()) out.push(...walk(p));
27
+ else out.push(p);
28
+ }
29
+ return out;
27
30
  }
28
31
 
29
32
  export async function initProject(cwd, args = []) {
30
- const positional = args.filter((a) => !a.startsWith("--"));
31
- const nameFlagIndex = args.indexOf("--name");
32
- const target = positional[0] ? path.resolve(cwd, positional[0]) : cwd;
33
- const name = nameFlagIndex >= 0 ? args[nameFlagIndex + 1] : path.basename(target);
33
+ const positional = args.filter((a) => !a.startsWith("--"));
34
+ const nameFlagIndex = args.indexOf("--name");
35
+ const target = positional[0] ? path.resolve(cwd, positional[0]) : cwd;
36
+ const name = nameFlagIndex >= 0 ? args[nameFlagIndex + 1] : path.basename(target);
34
37
 
35
- if (existsSync(target) && readdirSync(target).length > 0) {
36
- throw new Error(`${target} is not empty - init needs an empty (or new) directory`);
37
- }
38
- mkdirSync(target, { recursive: true });
38
+ if (existsSync(target) && readdirSync(target).length > 0) {
39
+ throw new Error(`${target} is not empty - init needs an empty (or new) directory`);
40
+ }
41
+ mkdirSync(target, { recursive: true });
39
42
 
40
- const files = walk(starter);
41
- for (const src of files) {
42
- const rel = path.relative(starter, src);
43
- const dest = path.join(target, rel);
44
- mkdirSync(path.dirname(dest), { recursive: true });
45
- const contents = readFileSync(src, "utf8").replaceAll("{{name}}", name);
46
- writeFileSync(dest, contents);
47
- }
43
+ const files = walk(starter);
44
+ for (const src of files) {
45
+ // The device's app folder is named after the device, so {{name}} has to be
46
+ // substituted in the PATH as well as in the contents.
47
+ const rel = path.relative(starter, src).replaceAll("{{name}}", name);
48
+ const dest = path.join(target, rel);
49
+ mkdirSync(path.dirname(dest), { recursive: true });
50
+ const contents = readFileSync(src, "utf8").replaceAll("{{name}}", name);
51
+ writeFileSync(dest, contents);
52
+ }
48
53
 
49
- console.log(`m4l-jweb: scaffolded "${name}" at ${target} (${files.length} files)`);
50
- console.log(`m4l-jweb: next steps:\n cd ${path.relative(cwd, target) || "."}\n pnpm install\n pnpm dev`);
51
- return target;
54
+ console.log(`m4l-jweb: scaffolded "${name}" at ${target} (${files.length} files)`);
55
+ console.log(`m4l-jweb: next steps:\n cd ${path.relative(cwd, target) || "."}\n pnpm install\n pnpm dev`);
56
+ return target;
52
57
  }
@@ -0,0 +1,5 @@
1
+ {
2
+ "useTabs": false,
3
+ "tabWidth": 2,
4
+ "printWidth": 150
5
+ }