@multiplatform.one/cli 6.4.3 → 6.6.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/README.md +23 -1
- package/lib/bin/multiplatformOne.mjs +5 -3
- package/lib/commands/initApp.mjs +209 -8
- package/lib/commands/updateApp.mjs +170 -16
- package/package.json +2 -2
- package/src/bin/multiplatformOne.ts +14 -1
- package/src/commands/initApp.spec.ts +369 -3
- package/src/commands/initApp.ts +324 -4
- package/src/commands/updateApp.spec.ts +81 -0
- package/src/commands/updateApp.ts +212 -6
- package/templates/pieces/frappe/universal/README.md.partial +32 -0
- package/templates/pieces/frappe/universal/docker/compose.frappe.yaml +52 -0
- package/templates/pieces/frappe/universal/env.example.partial +8 -0
- package/templates/pieces/frappe/universal/packages/config/config.json.partial +3 -0
- package/templates/pieces/gnome/universal/README.md.partial +68 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/main.tsx +165 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/polyfills.ts +10 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/components.ts +36 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/empty-module.ts +28 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/forms.ts +18 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/one.ts +89 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/shims/theme.ts +25 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/gnome/tamagui-barrel.ts +77 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/package.json.partial +18 -0
- package/templates/pieces/gnome/universal/apps/__NAME__/vite.config.gnome.ts +124 -0
- package/templates/pieces/gnome/universal/gitignore.partial +3 -0
- package/templates/pieces/gnome/universal/package.json.partial +6 -0
- package/templates/pieces/keycloak/universal/README.md.partial +19 -0
- package/templates/pieces/keycloak/universal/docker/compose.keycloak.yaml +27 -0
- package/templates/pieces/keycloak/universal/docker/keycloak/realm.json +43 -0
- package/templates/pieces/keycloak/universal/env.example.partial +9 -0
- package/templates/pieces/keycloak/universal/packages/config/config.json.partial +9 -0
- package/templates/pieces/tauri/universal/README.md.partial +18 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/package.json.partial +10 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/Cargo.toml +26 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/build.rs +3 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/capabilities/default.json +7 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/128x128.png +0 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/128x128@2x.png +0 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/32x32.png +0 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/icon.icns +0 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/icons/icon.ico +0 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +14 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/main.rs +9 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +43 -0
- package/templates/pieces/tauri/universal/gitignore.partial +3 -0
- package/templates/pieces/tauri/universal/package.json.partial +6 -0
- package/templates/pieces/vscode/universal/README.md.partial +17 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/package.json.partial +19 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vite.config.vscode.ts +17 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/env.d.ts +1 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/index.ts +13 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/views/helper.ts +14 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/extension/views/panel.ts +38 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/index.html +49 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/manifest.ts +33 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/package.json +7 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/tamagui.css +3 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/tsconfig.json +9 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/app.tsx +24 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/components/ErrorBoundary.tsx +41 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts/inter-400.woff2 +0 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts/inter-700.woff2 +0 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/fonts.css +21 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/main.tsx +35 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/router.tsx +58 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/useVSCodeTheme.ts +56 -0
- package/templates/pieces/vscode/universal/apps/__NAME__/vscode/webview/utils/vscode.ts +16 -0
- package/templates/pieces/vscode/universal/gitignore.partial +2 -0
- package/templates/pieces/vscode/universal/package.json.partial +6 -0
- package/templates/pieces/webext/universal/README.md.partial +16 -0
- package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +22 -0
- package/templates/pieces/webext/universal/apps/__NAME__/tsconfig.json.partial +5 -0
- package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext-background.ts +19 -0
- package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +35 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/assets/fonts/inter-400.woff2 +0 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/assets/fonts/inter-700.woff2 +0 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/background/main.ts +10 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/components/ErrorBoundary.tsx +41 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/env.ts +2 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/manifest.ts +45 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/prepare.ts +21 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/static/assets/icon-512.png +0 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/tamagui.css +3 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/tsconfig.json +9 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/views/popup/index.html +77 -0
- package/templates/pieces/webext/universal/apps/__NAME__/webext/views/popup/main.tsx +73 -0
- package/templates/pieces/webext/universal/gitignore.partial +2 -0
- package/templates/pieces/webext/universal/package.json.partial +6 -0
- package/types/commands/initApp.d.ts +43 -0
- package/types/commands/initApp.d.ts.map +1 -1
- package/types/commands/updateApp.d.ts +4 -0
- package/types/commands/updateApp.d.ts.map +1 -1
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
* pins matching paths to your HEAD version, exactly like the old script.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
18
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { tmpdir } from "node:os";
|
|
20
|
-
import { join } from "node:path";
|
|
20
|
+
import { basename, join } from "node:path";
|
|
21
21
|
import spawn from "nano-spawn";
|
|
22
22
|
import { generateVscodeConfig } from "../generateVscode";
|
|
23
|
-
import { cliVersion, initApp, readProvenance, writeProvenance } from "./initApp";
|
|
23
|
+
import { cliVersion, initApp, readProvenance, writeProvenance, type InitAppPiece } from "./initApp";
|
|
24
24
|
|
|
25
25
|
export interface UpdateAppOptions {
|
|
26
26
|
/** Skip pnpm install after the merge. */
|
|
@@ -69,6 +69,7 @@ async function scaffoldBaseline(
|
|
|
69
69
|
name: string,
|
|
70
70
|
template: "universal" | "app",
|
|
71
71
|
mpoVersion: string,
|
|
72
|
+
pieces: InitAppPiece[],
|
|
72
73
|
): Promise<{ dir: string; cleanup: () => void }> {
|
|
73
74
|
const parent = mkdtempSync(join(tmpdir(), "mpo-update-"));
|
|
74
75
|
const cleanup = () => rmSync(parent, { recursive: true, force: true });
|
|
@@ -79,6 +80,19 @@ async function scaffoldBaseline(
|
|
|
79
80
|
// scaffold baseline.
|
|
80
81
|
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
81
82
|
const scaffoldCapable = major > 6 || (major === 6 && minor >= 3);
|
|
83
|
+
// Composable pieces (--frappe/--keycloak/--tauri/--vscode/--webext)
|
|
84
|
+
// shipped in 6.5.0 — older published CLIs reject the flags. A 6.3/6.4
|
|
85
|
+
// baseline is scaffolded WITHOUT pieces: piece files then appear as
|
|
86
|
+
// ours-only additions relative to that base, which three-way merge keeps
|
|
87
|
+
// cleanly (they can't have come from those CLIs in the first place).
|
|
88
|
+
const piecesCapable = major > 6 || (major === 6 && minor >= 5);
|
|
89
|
+
const pieceFlags = piecesCapable ? pieces.map((piece) => `--${piece}`) : [];
|
|
90
|
+
if (pieces.length && scaffoldCapable && !piecesCapable && version !== current) {
|
|
91
|
+
console.warn(
|
|
92
|
+
`⚠️ @multiplatform.one/cli@${version} predates composable pieces (6.5.0); ` +
|
|
93
|
+
"the merge base is scaffolded without them.",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
82
96
|
if (!scaffoldCapable) {
|
|
83
97
|
console.warn(
|
|
84
98
|
`⚠️ @multiplatform.one/cli@${version} predates the consumer scaffolder (6.3.0); ` +
|
|
@@ -104,6 +118,7 @@ async function scaffoldBaseline(
|
|
|
104
118
|
"--mpo-version",
|
|
105
119
|
mpoVersion,
|
|
106
120
|
...templateFlag,
|
|
121
|
+
...pieceFlags,
|
|
107
122
|
],
|
|
108
123
|
{ cwd: parent, stdio: "inherit" },
|
|
109
124
|
);
|
|
@@ -131,6 +146,7 @@ async function scaffoldBaseline(
|
|
|
131
146
|
yes: true,
|
|
132
147
|
template,
|
|
133
148
|
version: mpoVersion,
|
|
149
|
+
pieces,
|
|
134
150
|
});
|
|
135
151
|
} finally {
|
|
136
152
|
process.chdir(previousCwd);
|
|
@@ -180,6 +196,167 @@ async function pinPathsToHead(
|
|
|
180
196
|
return pinned;
|
|
181
197
|
}
|
|
182
198
|
|
|
199
|
+
// ── package.json conflict auto-resolution ─────────────────────────
|
|
200
|
+
//
|
|
201
|
+
// The dominant conflict class in real updates is dependency-range drift:
|
|
202
|
+
// the project bumped a range between updates (ours ^6.4.2) while the new
|
|
203
|
+
// template carries its own (theirs ^6.4.3). Both sides changed the same
|
|
204
|
+
// JSON entry, so textual merge punts — but the resolution is mechanical:
|
|
205
|
+
// take the newer minimum version. A structural three-way merge handles it.
|
|
206
|
+
|
|
207
|
+
const UNRESOLVED = Symbol("unresolved");
|
|
208
|
+
/** Deleted-on-one-side sentinel (distinct from a key that never existed). */
|
|
209
|
+
const ABSENT = Symbol("absent");
|
|
210
|
+
|
|
211
|
+
const DEP_MAP_KEYS = new Set([
|
|
212
|
+
"dependencies",
|
|
213
|
+
"devDependencies",
|
|
214
|
+
"peerDependencies",
|
|
215
|
+
"optionalDependencies",
|
|
216
|
+
"overrides",
|
|
217
|
+
"resolutions",
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
/** Minimum concrete version of a simple range (`^x.y.z`, `~x.y.z`,
|
|
221
|
+
* `x.y.z`, optional prerelease). Anything fancier is not auto-resolved. */
|
|
222
|
+
function rangeMinimum(range: unknown): [number, number, number] | undefined {
|
|
223
|
+
if (typeof range !== "string") return undefined;
|
|
224
|
+
const match = range.match(/^[\^~]?(\d+)\.(\d+)\.(\d+)(?:-[\w.]+)?$/);
|
|
225
|
+
if (!match) return undefined;
|
|
226
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
230
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function jsonEqual(a: unknown, b: unknown): boolean {
|
|
234
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function mergeNode(
|
|
238
|
+
base: unknown,
|
|
239
|
+
ours: unknown,
|
|
240
|
+
theirs: unknown,
|
|
241
|
+
inDepMap: boolean,
|
|
242
|
+
): unknown | typeof UNRESOLVED | typeof ABSENT {
|
|
243
|
+
if (jsonEqual(ours, theirs)) return ours === undefined ? ABSENT : ours;
|
|
244
|
+
// Only one side diverged from base → that side wins (undefined = deletion).
|
|
245
|
+
if (jsonEqual(theirs, base)) return ours === undefined ? ABSENT : ours;
|
|
246
|
+
if (jsonEqual(ours, base)) return theirs === undefined ? ABSENT : theirs;
|
|
247
|
+
// Both diverged. Objects merge key-by-key; dep ranges take the newer
|
|
248
|
+
// minimum; everything else is a genuine conflict.
|
|
249
|
+
if (isPlainObject(ours) && isPlainObject(theirs)) {
|
|
250
|
+
const baseObject = isPlainObject(base) ? base : {};
|
|
251
|
+
const merged: Record<string, unknown> = {};
|
|
252
|
+
const keys = [...Object.keys(ours), ...Object.keys(theirs).filter((key) => !(key in ours))];
|
|
253
|
+
for (const key of keys) {
|
|
254
|
+
const child = mergeNode(
|
|
255
|
+
baseObject[key],
|
|
256
|
+
ours[key],
|
|
257
|
+
theirs[key],
|
|
258
|
+
inDepMap || DEP_MAP_KEYS.has(key),
|
|
259
|
+
);
|
|
260
|
+
if (child === UNRESOLVED) return UNRESOLVED;
|
|
261
|
+
if (child !== ABSENT) merged[key] = child;
|
|
262
|
+
}
|
|
263
|
+
return merged;
|
|
264
|
+
}
|
|
265
|
+
if (inDepMap) {
|
|
266
|
+
const oursMin = rangeMinimum(ours);
|
|
267
|
+
const theirsMin = rangeMinimum(theirs);
|
|
268
|
+
if (oursMin && theirsMin) {
|
|
269
|
+
for (let i = 0; i < 3; i++) {
|
|
270
|
+
if (oursMin[i]! > theirsMin[i]!) return ours;
|
|
271
|
+
if (oursMin[i]! < theirsMin[i]!) return theirs;
|
|
272
|
+
}
|
|
273
|
+
return theirs; // equal minimums, different spelling — template wins
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return UNRESOLVED;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Structural three-way merge of package.json content. Returns the merged
|
|
280
|
+
* object, or undefined when a divergence can't be auto-resolved (the file
|
|
281
|
+
* then keeps its textual conflict markers for manual resolution). */
|
|
282
|
+
export function threeWayMergePackageJson(
|
|
283
|
+
base: string | undefined,
|
|
284
|
+
ours: string,
|
|
285
|
+
theirs: string,
|
|
286
|
+
): Record<string, unknown> | undefined {
|
|
287
|
+
let baseJson: unknown;
|
|
288
|
+
let oursJson: unknown;
|
|
289
|
+
let theirsJson: unknown;
|
|
290
|
+
try {
|
|
291
|
+
baseJson = base === undefined ? {} : JSON.parse(base);
|
|
292
|
+
oursJson = JSON.parse(ours);
|
|
293
|
+
theirsJson = JSON.parse(theirs);
|
|
294
|
+
} catch {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
if (!isPlainObject(oursJson) || !isPlainObject(theirsJson)) return undefined;
|
|
298
|
+
const merged = mergeNode(baseJson, oursJson, theirsJson, false);
|
|
299
|
+
if (merged === UNRESOLVED || merged === ABSENT || !isPlainObject(merged)) return undefined;
|
|
300
|
+
return merged;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Replace auto-resolvable conflicted package.json files in `mergedTree`
|
|
304
|
+
* with their structural merge. Pure index plumbing, worktree untouched. */
|
|
305
|
+
async function autoResolveDependencyConflicts(
|
|
306
|
+
projectDir: string,
|
|
307
|
+
mergedTree: string,
|
|
308
|
+
conflicts: string[],
|
|
309
|
+
baseCommit: string,
|
|
310
|
+
headCommit: string,
|
|
311
|
+
nextCommit: string,
|
|
312
|
+
): Promise<{ tree: string; conflicts: string[]; resolved: string[] }> {
|
|
313
|
+
const candidates = conflicts.filter((file) => basename(file) === "package.json");
|
|
314
|
+
if (!candidates.length) return { tree: mergedTree, conflicts, resolved: [] };
|
|
315
|
+
|
|
316
|
+
const scratch = mkdtempSync(join(tmpdir(), "mpo-resolve-"));
|
|
317
|
+
const resolved: { path: string; oid: string }[] = [];
|
|
318
|
+
try {
|
|
319
|
+
for (const file of candidates) {
|
|
320
|
+
const show = (commit: string) =>
|
|
321
|
+
gitOut(projectDir, ["show", `${commit}:${file}`]).catch(() => undefined);
|
|
322
|
+
const [base, ours, theirs] = await Promise.all([
|
|
323
|
+
show(baseCommit),
|
|
324
|
+
show(headCommit),
|
|
325
|
+
show(nextCommit),
|
|
326
|
+
]);
|
|
327
|
+
// Add/delete conflicts stay manual — only both-sides-present merges.
|
|
328
|
+
if (ours === undefined || theirs === undefined) continue;
|
|
329
|
+
const merged = threeWayMergePackageJson(base, ours, theirs);
|
|
330
|
+
if (!merged) continue;
|
|
331
|
+
const blobFile = join(scratch, "blob.json");
|
|
332
|
+
writeFileSync(blobFile, `${JSON.stringify(merged, null, 2)}\n`);
|
|
333
|
+
const oid = await gitOut(projectDir, ["hash-object", "-w", "--", blobFile]);
|
|
334
|
+
resolved.push({ path: file, oid });
|
|
335
|
+
}
|
|
336
|
+
if (!resolved.length) return { tree: mergedTree, conflicts, resolved: [] };
|
|
337
|
+
|
|
338
|
+
const indexFile = join(scratch, "index");
|
|
339
|
+
const env = { GIT_INDEX_FILE: indexFile };
|
|
340
|
+
await git(projectDir, ["read-tree", mergedTree], env);
|
|
341
|
+
for (const entry of resolved) {
|
|
342
|
+
await git(
|
|
343
|
+
projectDir,
|
|
344
|
+
["update-index", "--add", "--cacheinfo", `100644,${entry.oid},${entry.path}`],
|
|
345
|
+
env,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
const tree = await gitOut(projectDir, ["write-tree"], env);
|
|
349
|
+
const resolvedPaths = new Set(resolved.map((entry) => entry.path));
|
|
350
|
+
return {
|
|
351
|
+
tree,
|
|
352
|
+
conflicts: conflicts.filter((file) => !resolvedPaths.has(file)),
|
|
353
|
+
resolved: [...resolvedPaths],
|
|
354
|
+
};
|
|
355
|
+
} finally {
|
|
356
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
183
360
|
export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
|
|
184
361
|
const projectDir = await gitOut(process.cwd(), ["rev-parse", "--show-toplevel"]).catch(() => {
|
|
185
362
|
throw new Error("mpo update must run inside a git repository");
|
|
@@ -212,15 +389,19 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
|
|
|
212
389
|
|
|
213
390
|
console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
|
|
214
391
|
console.log(` template: ${provenance.template}`);
|
|
392
|
+
if (provenance.pieces.length) console.log(` pieces: ${provenance.pieces.join(", ")}`);
|
|
215
393
|
console.log(` project: ${provenance.name}\n`);
|
|
216
394
|
|
|
217
|
-
// 1. Regenerate the ORIGINAL scaffold (merge base).
|
|
395
|
+
// 1. Regenerate the ORIGINAL scaffold (merge base). Pieces recorded in
|
|
396
|
+
// provenance are reproduced in BOTH baselines so piece files merge as
|
|
397
|
+
// template content, not as phantom diffs.
|
|
218
398
|
console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
|
|
219
399
|
const base = await scaffoldBaseline(
|
|
220
400
|
provenance.cliVersion,
|
|
221
401
|
provenance.name,
|
|
222
402
|
provenance.template,
|
|
223
403
|
provenance.mpoVersion,
|
|
404
|
+
provenance.pieces,
|
|
224
405
|
);
|
|
225
406
|
|
|
226
407
|
// 2. Generate the CURRENT scaffold (theirs).
|
|
@@ -230,6 +411,7 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
|
|
|
230
411
|
provenance.name,
|
|
231
412
|
provenance.template,
|
|
232
413
|
targetMpoRange,
|
|
414
|
+
provenance.pieces,
|
|
233
415
|
);
|
|
234
416
|
|
|
235
417
|
// .mpo.json is TOOL-owned, never merged: pre-provenance CLIs (≤6.3.0)
|
|
@@ -289,6 +471,7 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
|
|
|
289
471
|
cliVersion: currentVersion,
|
|
290
472
|
name: provenance.name,
|
|
291
473
|
mpoVersion: targetMpoRange,
|
|
474
|
+
pieces: provenance.pieces,
|
|
292
475
|
};
|
|
293
476
|
|
|
294
477
|
// .updateignore pathspecs (+ pnpm-lock.yaml) pin to the pre-update HEAD.
|
|
@@ -309,6 +492,22 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
|
|
|
309
492
|
(file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)),
|
|
310
493
|
);
|
|
311
494
|
|
|
495
|
+
// Dependency-range drift in package.json files resolves mechanically
|
|
496
|
+
// (newer minimum version wins) — don't punt those to the user.
|
|
497
|
+
const autoResolved = await autoResolveDependencyConflicts(
|
|
498
|
+
projectDir,
|
|
499
|
+
mergedTree,
|
|
500
|
+
conflicts,
|
|
501
|
+
baseCommit,
|
|
502
|
+
head,
|
|
503
|
+
nextCommit,
|
|
504
|
+
);
|
|
505
|
+
mergedTree = autoResolved.tree;
|
|
506
|
+
conflicts = autoResolved.conflicts;
|
|
507
|
+
for (const file of autoResolved.resolved) {
|
|
508
|
+
console.log(` auto-resolved: ${file} (dependency ranges)`);
|
|
509
|
+
}
|
|
510
|
+
|
|
312
511
|
// No-op detection: identical trees mean the project already carries the
|
|
313
512
|
// current template — don't stack empty merge commits.
|
|
314
513
|
const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
|
|
@@ -332,6 +531,7 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
|
|
|
332
531
|
return;
|
|
333
532
|
}
|
|
334
533
|
|
|
534
|
+
let cleanMergeMessage: string | undefined;
|
|
335
535
|
if (!conflicts.length) {
|
|
336
536
|
const message = `chore: mpo update ${provenance.cliVersion} → ${currentVersion}`;
|
|
337
537
|
const mergeCommit = await gitOut(projectDir, [
|
|
@@ -356,8 +556,7 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
|
|
|
356
556
|
}
|
|
357
557
|
await git(projectDir, ["add", "--", ".mpo.json", ".vscode"]);
|
|
358
558
|
await git(projectDir, ["commit", "--amend", "--no-edit", "--quiet"]);
|
|
359
|
-
|
|
360
|
-
console.log(`\n✅ merged cleanly → ${amended} ("${message}")`);
|
|
559
|
+
cleanMergeMessage = message;
|
|
361
560
|
} else {
|
|
362
561
|
// Land the merged tree (with conflict markers) in index + worktree for
|
|
363
562
|
// manual resolution; pins are already part of the tree.
|
|
@@ -389,6 +588,13 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
|
|
|
389
588
|
}
|
|
390
589
|
}
|
|
391
590
|
}
|
|
591
|
+
|
|
592
|
+
// Reported last — the lockfile amend above rewrites the commit, so an
|
|
593
|
+
// earlier print would show a hash that no longer exists.
|
|
594
|
+
if (cleanMergeMessage) {
|
|
595
|
+
const finalHash = await gitOut(projectDir, ["rev-parse", "--short", "HEAD"]);
|
|
596
|
+
console.log(`\n✅ merged cleanly → ${finalHash} ("${cleanMergeMessage}")`);
|
|
597
|
+
}
|
|
392
598
|
} finally {
|
|
393
599
|
base.cleanup();
|
|
394
600
|
next.cleanup();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
## Frappe backend (`mpo init --frappe`)
|
|
2
|
+
|
|
3
|
+
`apps/__NAME__/src/app.ts` wires `createApp({ frappe: … })`: the
|
|
4
|
+
`FrappeProvider` mounts when `FRAPPE_ENABLED=1` and talks to `FRAPPE_URL`
|
|
5
|
+
(both are `public` env keys in `packages/config/config.json`, loaded from
|
|
6
|
+
`.env` — start from `.env.example`).
|
|
7
|
+
|
|
8
|
+
Run a dev bench with docker:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
docker compose -f docker/compose.frappe.yaml up -d
|
|
12
|
+
docker compose -f docker/compose.frappe.yaml exec bench bash
|
|
13
|
+
|
|
14
|
+
# inside the container (first time only)
|
|
15
|
+
bench init --skip-redis-config-generation frappe-bench
|
|
16
|
+
cd frappe-bench
|
|
17
|
+
bench set-config -g db_host mariadb
|
|
18
|
+
bench set-config -g redis_cache redis://redis-cache:6379
|
|
19
|
+
bench set-config -g redis_queue redis://redis-queue:6379
|
|
20
|
+
bench set-config -g redis_socketio redis://redis-queue:6379
|
|
21
|
+
bench new-site __NAME__.localhost \
|
|
22
|
+
--mariadb-user-host-login-scope=% \
|
|
23
|
+
--db-root-password frappe --admin-password admin
|
|
24
|
+
bench --site __NAME__.localhost set-config developer_mode 1
|
|
25
|
+
|
|
26
|
+
# every dev session
|
|
27
|
+
bench start
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then set `FRAPPE_URL=http://__NAME__.localhost:8000` (or keep
|
|
31
|
+
`http://localhost:8000` and make the site the default with
|
|
32
|
+
`bench use __NAME__.localhost`) and restart `pnpm dev`.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Dev Frappe bench for __NAME__ (mpo init --frappe).
|
|
2
|
+
#
|
|
3
|
+
# docker compose -f docker/compose.frappe.yaml up -d
|
|
4
|
+
# docker compose -f docker/compose.frappe.yaml exec bench bash
|
|
5
|
+
#
|
|
6
|
+
# The frappe/bench image is a development sandbox (frappe user + bench
|
|
7
|
+
# tooling); the bench itself is created ONCE inside the container — see the
|
|
8
|
+
# "Frappe backend" section of the README for the init commands. State
|
|
9
|
+
# persists in the named volumes.
|
|
10
|
+
services:
|
|
11
|
+
bench:
|
|
12
|
+
image: frappe/bench:latest
|
|
13
|
+
command: sleep infinity
|
|
14
|
+
working_dir: /home/frappe
|
|
15
|
+
environment:
|
|
16
|
+
SHELL: /bin/bash
|
|
17
|
+
volumes:
|
|
18
|
+
- bench:/home/frappe
|
|
19
|
+
ports:
|
|
20
|
+
- "8000:8000" # bench serve (FRAPPE_URL)
|
|
21
|
+
- "9000:9000" # socket.io realtime
|
|
22
|
+
depends_on:
|
|
23
|
+
- mariadb
|
|
24
|
+
- redis-cache
|
|
25
|
+
- redis-queue
|
|
26
|
+
|
|
27
|
+
mariadb:
|
|
28
|
+
image: mariadb:10.6
|
|
29
|
+
command:
|
|
30
|
+
- --character-set-server=utf8mb4
|
|
31
|
+
- --collation-server=utf8mb4_unicode_ci
|
|
32
|
+
- --skip-character-set-client-handshake
|
|
33
|
+
- --skip-innodb-read-only-compressed
|
|
34
|
+
environment:
|
|
35
|
+
MARIADB_ROOT_PASSWORD: frappe
|
|
36
|
+
volumes:
|
|
37
|
+
- mariadb:/var/lib/mysql
|
|
38
|
+
healthcheck:
|
|
39
|
+
test: ["CMD", "mariadb-admin", "ping", "-h", "127.0.0.1", "-uroot", "-pfrappe"]
|
|
40
|
+
interval: 5s
|
|
41
|
+
timeout: 5s
|
|
42
|
+
retries: 10
|
|
43
|
+
|
|
44
|
+
redis-cache:
|
|
45
|
+
image: redis:alpine
|
|
46
|
+
|
|
47
|
+
redis-queue:
|
|
48
|
+
image: redis:alpine
|
|
49
|
+
|
|
50
|
+
volumes:
|
|
51
|
+
bench: {}
|
|
52
|
+
mariadb: {}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Frappe backend (mpo init --frappe) — copy to .env. Keys listed as `public`
|
|
2
|
+
# in packages/config/config.json are baked into the client bundle at build
|
|
3
|
+
# time (VITE_MP_CONFIG); FRAPPE_ENABLED=1 mounts the FrappeProvider wired in
|
|
4
|
+
# apps/__NAME__/src/app.ts.
|
|
5
|
+
FRAPPE_ENABLED=1
|
|
6
|
+
# Bench URL the client talks to (docker/compose.frappe.yaml serves :8000).
|
|
7
|
+
# Falls back to BASE_URL, then http://localhost:8000.
|
|
8
|
+
FRAPPE_URL=http://localhost:8000
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
## GNOME desktop (GTK4 / GJS)
|
|
2
|
+
|
|
3
|
+
`mpo init --gnome` added a native GNOME target. Unlike the Tauri target,
|
|
4
|
+
which ships the web build inside a system webview, this renders **real
|
|
5
|
+
GTK4 widgets** through [react-gnome](https://github.com/react-gjs) — your
|
|
6
|
+
Tamagui components become `Gtk.Widget`s laid out by Yoga, with no DOM
|
|
7
|
+
anywhere in the process.
|
|
8
|
+
|
|
9
|
+
### Prerequisites
|
|
10
|
+
|
|
11
|
+
GJS and the GTK stack are system packages, not npm ones:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Fedora / RHEL
|
|
15
|
+
sudo dnf install gjs gtk4-devel libadwaita-devel gobject-introspection-devel
|
|
16
|
+
|
|
17
|
+
# Debian / Ubuntu
|
|
18
|
+
sudo apt install gjs libgtk-4-dev libadwaita-1-dev libgirepository1.0-dev
|
|
19
|
+
|
|
20
|
+
# macOS (development only — GTK apps ship on Linux)
|
|
21
|
+
brew install gjs gtk4 libadwaita gobject-introspection
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
On macOS, `gjs` needs the Homebrew libraries on its loader path:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
export DYLD_FALLBACK_LIBRARY_PATH="$(brew --prefix gjs)/lib:$(brew --prefix)/lib"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Run it
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm gnome # build and open an interactive Adw.ApplicationWindow
|
|
34
|
+
pnpm gnome:capture # build, render, save a PNG, quit — works headless
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`gnome:capture` writes `/tmp/__NAME__-gnome.png`. It polls for a render
|
|
38
|
+
node across frames rather than waiting a fixed delay, because offscreen
|
|
39
|
+
surfaces return null transiently after an invalidation — which is how you
|
|
40
|
+
get a blank screenshot that looks like a broken app.
|
|
41
|
+
|
|
42
|
+
### How it is wired
|
|
43
|
+
|
|
44
|
+
| Path | Role |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `apps/__NAME__/gnome/main.tsx` | Entry — Gtk init, Yoga, providers, window |
|
|
47
|
+
| `apps/__NAME__/gnome/polyfills.ts` | Browser-API globals. **Must be the first import** |
|
|
48
|
+
| `apps/__NAME__/gnome/tamagui-barrel.ts` | The granular `@tamagui/*` set this app reaches |
|
|
49
|
+
| `apps/__NAME__/gnome/shims/` | Barrels narrowed to their GJS-safe subgraph |
|
|
50
|
+
| `apps/__NAME__/vite.config.gnome.ts` | Separate library build → `dist/gnome/main.js` |
|
|
51
|
+
|
|
52
|
+
Two things surprise people:
|
|
53
|
+
|
|
54
|
+
**One's router is not mounted.** Its native path needs the React Native
|
|
55
|
+
fabric renderer, which does not exist here. `gnome/shims/one.ts` backs
|
|
56
|
+
`useRouter`, `usePathname` and `Link` with the framework's navigator seam
|
|
57
|
+
instead. With no `Adw.NavigationView` attached it degrades — pushes are
|
|
58
|
+
ignored, `Link` renders its children. Attach a navigation host and the
|
|
59
|
+
same calls become real push/pop with the back button, Escape and
|
|
60
|
+
swipe-back. Your screens do not change.
|
|
61
|
+
|
|
62
|
+
**Barrels are aliased to shims.** The full `@multiplatform.one/components`
|
|
63
|
+
barrel pulls expo-asset, flash-list and phosphor; `theme` pulls
|
|
64
|
+
react-dom/server and node:fs. Those fail at module init, before a widget
|
|
65
|
+
exists. The shims re-export only the GJS-safe subgraph, so adding a
|
|
66
|
+
component is usually one line in a shim plus one granular `@tamagui/*`
|
|
67
|
+
export in the barrel. A missing export is a build error naming the
|
|
68
|
+
symbol.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// GNOME (GTK4 / GJS / libadwaita) entry for __NAME__.
|
|
2
|
+
//
|
|
3
|
+
// Renders the home screen inside a real Adw.ApplicationWindow via
|
|
4
|
+
// react-gnome. Every box you see is an actual GTK widget laid out by Yoga
|
|
5
|
+
// — there is no webview and no DOM.
|
|
6
|
+
//
|
|
7
|
+
// pnpm gnome interactive window
|
|
8
|
+
// pnpm gnome:capture render, save a PNG, quit (works headless)
|
|
9
|
+
// gjs -m dist/gnome/main.js [outPath|interactive]
|
|
10
|
+
//
|
|
11
|
+
// ONE'S ROUTER IS NOT MOUNTED. Its native path goes through
|
|
12
|
+
// react-native-screens, which needs the RN fabric renderer that does not
|
|
13
|
+
// exist here. The index route's component is imported directly, and
|
|
14
|
+
// gnome/shims/one.ts backs the router API the screens call. Give this
|
|
15
|
+
// entry an Adw.NavigationView host when you want real push/pop.
|
|
16
|
+
//
|
|
17
|
+
// PROVIDERS. Only what the home screen consumes: Tamagui with the app's
|
|
18
|
+
// real config, and i18next with the app's real resources. Add others as
|
|
19
|
+
// screens need them — a provider whose graph reaches the DOM will fail at
|
|
20
|
+
// module init, not at render.
|
|
21
|
+
|
|
22
|
+
// Browser-API polyfills FIRST. Tamagui and RN-shaped dependencies probe
|
|
23
|
+
// window/document/matchMedia at module-init time, and imports are hoisted
|
|
24
|
+
// — first-import order is what guarantees the globals exist by then.
|
|
25
|
+
import "./polyfills";
|
|
26
|
+
|
|
27
|
+
import "gi://Gtk?version=4.0";
|
|
28
|
+
import "gi://Adw?version=1";
|
|
29
|
+
import Adw from "gi://Adw";
|
|
30
|
+
import GLib from "gi://GLib";
|
|
31
|
+
import Graphene from "gi://Graphene";
|
|
32
|
+
import Gtk from "gi://Gtk";
|
|
33
|
+
import {
|
|
34
|
+
AdwApplicationWindowElement,
|
|
35
|
+
AdwHeaderBar,
|
|
36
|
+
ToolbarView,
|
|
37
|
+
loadYogaForGjs,
|
|
38
|
+
render,
|
|
39
|
+
setYoga,
|
|
40
|
+
} from "@react-gnome/renderer";
|
|
41
|
+
import { HomeScreen } from "@features/__NAME__";
|
|
42
|
+
import { resources } from "@package/i18n/resources";
|
|
43
|
+
import i18n from "i18next";
|
|
44
|
+
import React from "react";
|
|
45
|
+
import { initReactI18next } from "react-i18next";
|
|
46
|
+
import { config } from "../config/tamagui.config";
|
|
47
|
+
import { TamaguiProvider } from "./tamagui-barrel";
|
|
48
|
+
|
|
49
|
+
declare const ARGV: string[];
|
|
50
|
+
declare const imports: { system: { exit(code: number): void } };
|
|
51
|
+
|
|
52
|
+
// initImmediate:false makes init synchronous — resources are bundled, so
|
|
53
|
+
// useTranslation must not suspend on first render.
|
|
54
|
+
i18n.use(initReactI18next).init({
|
|
55
|
+
compatibilityJSON: "v4",
|
|
56
|
+
defaultNS: "common",
|
|
57
|
+
ns: ["common"],
|
|
58
|
+
fallbackLng: "en",
|
|
59
|
+
lng: "en",
|
|
60
|
+
resources,
|
|
61
|
+
interpolation: { escapeValue: false },
|
|
62
|
+
initImmediate: false,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const arg0 = typeof ARGV !== "undefined" ? ARGV[0] : undefined;
|
|
66
|
+
const interactive = arg0 === "interactive" || arg0 === "i";
|
|
67
|
+
const outPath = !interactive && arg0 ? arg0 : "/tmp/__NAME__-gnome.png";
|
|
68
|
+
|
|
69
|
+
Gtk.init();
|
|
70
|
+
setYoga(await loadYogaForGjs());
|
|
71
|
+
|
|
72
|
+
function GnomeApp(): React.ReactElement {
|
|
73
|
+
return (
|
|
74
|
+
<TamaguiProvider config={config} defaultTheme="light">
|
|
75
|
+
<React.Suspense fallback={null}>
|
|
76
|
+
<HomeScreen />
|
|
77
|
+
</React.Suspense>
|
|
78
|
+
</TamaguiProvider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const app = new Adw.Application({ applicationId: "one.multiplatform.__NAME_APPID__", flags: 0 });
|
|
83
|
+
|
|
84
|
+
app.connect("activate", () => {
|
|
85
|
+
const window = new AdwApplicationWindowElement({
|
|
86
|
+
title: "__NAME_PASCAL__",
|
|
87
|
+
application: app,
|
|
88
|
+
defaultWidth: 900,
|
|
89
|
+
defaultHeight: 640,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
render(
|
|
93
|
+
<ToolbarView>
|
|
94
|
+
<AdwHeaderBar toolbarSlot="top" />
|
|
95
|
+
<GnomeApp />
|
|
96
|
+
</ToolbarView>,
|
|
97
|
+
window,
|
|
98
|
+
);
|
|
99
|
+
window.widget.present();
|
|
100
|
+
|
|
101
|
+
if (interactive) return;
|
|
102
|
+
|
|
103
|
+
// Headless capture. A render node is transiently null on offscreen
|
|
104
|
+
// surfaces right after an invalidation, so poll across frames rather
|
|
105
|
+
// than trusting one fixed delay — a single timeout yields a blank PNG
|
|
106
|
+
// often enough to be useless as evidence.
|
|
107
|
+
const dir = outPath.substring(0, outPath.lastIndexOf("/")) || ".";
|
|
108
|
+
GLib.mkdir_with_parents(dir, 0o755);
|
|
109
|
+
const wait = Number.parseInt(GLib.getenv("RGN_WAIT") ?? "800", 10);
|
|
110
|
+
GLib.timeout_add(GLib.PRIORITY_DEFAULT, wait, () => {
|
|
111
|
+
const widget = window.widget as Gtk.Widget & {
|
|
112
|
+
get_width(): number;
|
|
113
|
+
get_height(): number;
|
|
114
|
+
get_native(): Gtk.Native | null;
|
|
115
|
+
queue_draw(): void;
|
|
116
|
+
};
|
|
117
|
+
const WidgetPaintable = (
|
|
118
|
+
Gtk as unknown as { WidgetPaintable: { new: (w: Gtk.Widget) => unknown } }
|
|
119
|
+
).WidgetPaintable;
|
|
120
|
+
let tries = 0;
|
|
121
|
+
const poll = (): boolean => {
|
|
122
|
+
try {
|
|
123
|
+
const width = widget.get_width();
|
|
124
|
+
const height = widget.get_height();
|
|
125
|
+
if (width > 0 && height > 0) {
|
|
126
|
+
const paintable = WidgetPaintable.new(widget) as {
|
|
127
|
+
snapshot(s: Gtk.Snapshot, w: number, h: number): void;
|
|
128
|
+
};
|
|
129
|
+
const snapshot = new Gtk.Snapshot();
|
|
130
|
+
paintable.snapshot(snapshot, width, height);
|
|
131
|
+
const node = snapshot.to_node();
|
|
132
|
+
if (node) {
|
|
133
|
+
const renderer = widget.get_native()?.get_renderer();
|
|
134
|
+
if (!renderer) throw new Error("no Gsk.Renderer attached to the window");
|
|
135
|
+
const viewport = new Graphene.Rect();
|
|
136
|
+
viewport.init(0, 0, width, height);
|
|
137
|
+
renderer.render_texture(node, viewport).save_to_png(outPath);
|
|
138
|
+
console.log(`captured ${width}x${height} -> ${outPath}`);
|
|
139
|
+
app.quit();
|
|
140
|
+
return GLib.SOURCE_REMOVE;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (++tries >= 80) {
|
|
144
|
+
console.error(`snapshot produced no render node after ${tries} frames`);
|
|
145
|
+
app.quit();
|
|
146
|
+
imports.system.exit(4);
|
|
147
|
+
return GLib.SOURCE_REMOVE;
|
|
148
|
+
}
|
|
149
|
+
widget.queue_draw();
|
|
150
|
+
return GLib.SOURCE_CONTINUE;
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error(`snapshot failed: ${String(e)}`);
|
|
153
|
+
app.quit();
|
|
154
|
+
imports.system.exit(5);
|
|
155
|
+
return GLib.SOURCE_REMOVE;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
if (poll() !== GLib.SOURCE_REMOVE) {
|
|
159
|
+
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, poll);
|
|
160
|
+
}
|
|
161
|
+
return GLib.SOURCE_REMOVE;
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
app.run([]);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Browser-API polyfills for the GNOME target. MUST be the first import of
|
|
2
|
+
// gnome/main.tsx: JS imports are hoisted, so first-import order is what
|
|
3
|
+
// guarantees window/document/matchMedia/URL exist before Tamagui and the
|
|
4
|
+
// RN-shaped dependencies probe for them at module-init time.
|
|
5
|
+
//
|
|
6
|
+
// The implementation is packaged — @react-gnome/react-native/polyfills
|
|
7
|
+
// installs each global conditionally, so anything you define before this
|
|
8
|
+
// import wins. Keep this file as the app's stable local import point.
|
|
9
|
+
|
|
10
|
+
import "@react-gnome/react-native/polyfills";
|