@multiplatform.one/cli 7.11.0 → 7.16.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 +121 -111
- package/bin/createMultiplatformApp.mjs +39 -37
- package/lib/bin/multiplatformOne.mjs +65 -75
- package/lib/commands/addApp.mjs +78 -0
- package/lib/commands/adoptApp.mjs +11 -7
- package/lib/commands/doctypeTypes.mjs +148 -0
- package/lib/commands/e2e.mjs +2 -2
- package/lib/commands/init.mjs +142 -459
- package/lib/commands/merge.mjs +359 -0
- package/lib/commands/pieces.mjs +236 -0
- package/lib/commands/provenance.mjs +108 -0
- package/lib/commands/scaffold.mjs +105 -0
- package/lib/commands/updateApp.mjs +211 -528
- package/lib/source.json +5 -0
- package/lib/source.mjs +63 -0
- package/package.json +6 -6
- package/scripts/frappe-bootstrap.sh +29 -3
- package/scripts/frappe-dev.sh +1 -2
- package/scripts/frappe-helpers.sh +50 -2
- package/scripts/frappe.sh +5 -0
- package/scripts/scaffold.sh +937 -0
- package/scripts/stamp-source.mjs +51 -0
- package/scripts/stamp-source.spec.ts +86 -0
- package/src/bin/multiplatformOne.ts +116 -226
- package/src/commands/addApp.ts +102 -0
- package/src/commands/adoptApp.spec.ts +5 -1
- package/src/commands/adoptApp.ts +15 -10
- package/src/commands/doctypeTypes.spec.ts +155 -0
- package/src/commands/doctypeTypes.ts +214 -0
- package/src/commands/init.spec.ts +66 -0
- package/src/commands/init.ts +201 -643
- package/src/commands/merge.spec.ts +81 -0
- package/src/commands/merge.ts +411 -0
- package/src/commands/pieces.spec.ts +62 -0
- package/src/commands/pieces.ts +340 -0
- package/src/commands/provenance.spec.ts +92 -0
- package/src/commands/provenance.ts +169 -0
- package/src/commands/scaffold.ts +131 -0
- package/src/commands/updateApp.spec.ts +540 -331
- package/src/commands/updateApp.ts +255 -668
- package/src/source.spec.ts +45 -0
- package/src/source.ts +88 -0
- package/templates/AGENTS.md +161 -0
- package/templates/README.md +62 -0
- package/templates/pieces/frappe/universal/README.md.partial +7 -32
- package/templates/pieces/frappe/universal/env.example.partial +3 -3
- package/templates/pieces/frappe-bench/universal/README.md.partial +28 -0
- package/templates/pieces/{frappe → frappe-bench}/universal/docker/compose.frappe.yaml +2 -2
- package/templates/pieces/tauri/universal/README.md.partial +7 -5
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/dev.rs +463 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +6 -0
- package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +2 -1
- package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +1 -0
- package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +6 -2
- package/types/bin/multiplatformOne.d.ts.map +1 -1
- package/types/commands/addApp.d.ts +20 -0
- package/types/commands/addApp.d.ts.map +1 -0
- package/types/commands/adoptApp.d.ts +4 -3
- package/types/commands/adoptApp.d.ts.map +1 -1
- package/types/commands/doctypeTypes.d.ts +40 -0
- package/types/commands/doctypeTypes.d.ts.map +1 -0
- package/types/commands/init.d.ts +30 -13
- package/types/commands/init.d.ts.map +1 -1
- package/types/commands/merge.d.ts +54 -0
- package/types/commands/merge.d.ts.map +1 -0
- package/types/commands/pieces.d.ts +54 -0
- package/types/commands/pieces.d.ts.map +1 -0
- package/types/commands/provenance.d.ts +52 -0
- package/types/commands/provenance.d.ts.map +1 -0
- package/types/commands/scaffold.d.ts +43 -0
- package/types/commands/scaffold.d.ts.map +1 -0
- package/types/commands/updateApp.d.ts +19 -27
- package/types/commands/updateApp.d.ts.map +1 -1
- package/types/source.d.ts +35 -0
- package/types/source.d.ts.map +1 -0
- package/lib/commands/initApp.mjs +0 -431
- package/lib/generateVscode.mjs +0 -184
- package/scripts/clone.sh +0 -17
- package/scripts/update.sh +0 -46
- package/src/commands/initApp.spec.ts +0 -720
- package/src/commands/initApp.ts +0 -706
- package/src/generateVscode.ts +0 -238
- package/templates/app/AGENTS.md +0 -142
- package/templates/app/README.md +0 -62
- package/templates/app/apps/__NAME__/index.html +0 -36
- package/templates/app/apps/__NAME__/package.json +0 -31
- package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
- package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
- package/templates/app/apps/__NAME__/src/App.tsx +0 -9
- package/templates/app/apps/__NAME__/src/main.tsx +0 -14
- package/templates/app/apps/__NAME__/tsconfig.json +0 -8
- package/templates/app/apps/__NAME__/vite.config.ts +0 -17
- package/templates/app/features/__NAME__/package.json +0 -34
- package/templates/app/features/__NAME__/src/HomeScreen.tsx +0 -70
- package/templates/app/features/__NAME__/src/index.ts +0 -1
- package/templates/app/features/__NAME__/tsconfig.json +0 -5
- package/templates/app/gitignore +0 -9
- package/templates/app/npmrc +0 -2
- package/templates/app/oxlintrc.json +0 -39
- package/templates/app/package.json +0 -38
- package/templates/app/packages/config/config.json +0 -4
- package/templates/app/packages/config/index.ts +0 -5
- package/templates/app/packages/config/package.json +0 -17
- package/templates/app/packages/config/tsconfig.json +0 -5
- package/templates/app/pnpm-workspace.yaml +0 -4
- package/templates/app/tsconfig.base.json +0 -16
- package/templates/universal/AGENTS.md +0 -142
- package/templates/universal/README.md +0 -97
- package/templates/universal/apps/__NAME__/app.config.js +0 -31
- package/templates/universal/apps/__NAME__/config/tamagui.config.ts +0 -4
- package/templates/universal/apps/__NAME__/package.json +0 -62
- package/templates/universal/apps/__NAME__/polyfills.native.ts +0 -23
- package/templates/universal/apps/__NAME__/polyfills.ts +0 -3
- package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
- package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
- package/templates/universal/apps/__NAME__/routes/_layout.tsx +0 -22
- package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +0 -21
- package/templates/universal/apps/__NAME__/routes/about.tsx +0 -3
- package/templates/universal/apps/__NAME__/routes/index.tsx +0 -3
- package/templates/universal/apps/__NAME__/src/app.ts +0 -10
- package/templates/universal/apps/__NAME__/tamagui.css +0 -3
- package/templates/universal/apps/__NAME__/tsconfig.json +0 -8
- package/templates/universal/apps/__NAME__/types.d.ts +0 -9
- package/templates/universal/apps/__NAME__/vite.config.ts +0 -20
- package/templates/universal/features/__NAME__/package.json +0 -35
- package/templates/universal/features/__NAME__/src/AboutScreen.tsx +0 -27
- package/templates/universal/features/__NAME__/src/AppLayout.tsx +0 -50
- package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +0 -22
- package/templates/universal/features/__NAME__/src/ContactForm.tsx +0 -55
- package/templates/universal/features/__NAME__/src/HomeScreen.tsx +0 -40
- package/templates/universal/features/__NAME__/src/index.ts +0 -4
- package/templates/universal/features/__NAME__/src/screens.spec.ts +0 -12
- package/templates/universal/features/__NAME__/tsconfig.json +0 -5
- package/templates/universal/features/__NAME__/vitest.config.mjs +0 -7
- package/templates/universal/gitignore +0 -14
- package/templates/universal/npmrc +0 -3
- package/templates/universal/oxlintrc.json +0 -39
- package/templates/universal/package.json +0 -51
- package/templates/universal/packages/config/config.json +0 -4
- package/templates/universal/packages/config/index.ts +0 -5
- package/templates/universal/packages/config/package.json +0 -17
- package/templates/universal/packages/config/tsconfig.json +0 -5
- package/templates/universal/packages/i18n/en/common.json +0 -30
- package/templates/universal/packages/i18n/es/common.json +0 -30
- package/templates/universal/packages/i18n/i18n.d.ts +0 -16
- package/templates/universal/packages/i18n/index.ts +0 -26
- package/templates/universal/packages/i18n/package.json +0 -20
- package/templates/universal/packages/i18n/resources.ts +0 -13
- package/templates/universal/packages/i18n/tsconfig.json +0 -5
- package/templates/universal/packages/themes/accent.ts +0 -32
- package/templates/universal/packages/themes/base.ts +0 -50
- package/templates/universal/packages/themes/fonts.ts +0 -8
- package/templates/universal/packages/themes/index.ts +0 -33
- package/templates/universal/packages/themes/package.json +0 -23
- package/templates/universal/packages/themes/tamagui.config.ts +0 -14
- package/templates/universal/packages/themes/tsconfig.json +0 -5
- package/templates/universal/pnpm-workspace.yaml +0 -4
- package/templates/universal/tsconfig.base.json +0 -21
- package/templates/universal/types/env.d.ts +0 -9
- package/types/commands/initApp.d.ts +0 -114
- package/types/commands/initApp.d.ts.map +0 -1
- package/types/generateVscode.d.ts +0 -7
- package/types/generateVscode.d.ts.map +0 -1
|
@@ -1,494 +1,126 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `mpo update
|
|
3
|
-
*
|
|
4
|
-
* your changes on top), rebuilt with a TRUE three-way merge:
|
|
2
|
+
* `mpo update`: bring the monorepo's changes into a project as a three-way
|
|
3
|
+
* merge that is NEVER committed.
|
|
5
4
|
*
|
|
6
|
-
* base
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* ours
|
|
10
|
-
* theirs=
|
|
5
|
+
* base = the scaffold the project started from: the monorepo at the
|
|
6
|
+
* commit recorded in .mpo.json, pruned with the recorded apps
|
|
7
|
+
* and services
|
|
8
|
+
* ours = the project's HEAD (every commit the project made)
|
|
9
|
+
* theirs = the same scaffold at the commit the running CLI was built from
|
|
10
|
+
* (or --source/--checkout)
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* Every product app is a renamed apps/one on both sides, so a change to
|
|
13
|
+
* the One app lands in each of them. The merged tree goes into the index
|
|
14
|
+
* and worktree; the commit line is printed; a dirty tree is refused up
|
|
15
|
+
* front. Adopted projects (`mpo adopt`) merge their piece fragments the
|
|
16
|
+
* same way; projects from the retired template scaffolder reconcile from an
|
|
17
|
+
* empty base once and are clone-derived afterwards.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
20
21
|
import { tmpdir } from "node:os";
|
|
21
22
|
import { basename, join } from "node:path";
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
23
|
+
import { isGitUrl, monorepoCheckout, monorepoUrl, resolveSource, type SourceSpec } from "../source";
|
|
24
|
+
import {
|
|
25
|
+
finishMerge,
|
|
26
|
+
git,
|
|
27
|
+
gitOut,
|
|
28
|
+
landTree,
|
|
29
|
+
mergeScaffolds,
|
|
30
|
+
projectRootOf,
|
|
31
|
+
requireCleanTree,
|
|
32
|
+
} from "./merge";
|
|
33
|
+
import { scaffoldPieces, withSplitPieces } from "./pieces";
|
|
24
34
|
import {
|
|
25
35
|
PROVENANCE_FILE,
|
|
26
36
|
cliVersion,
|
|
27
|
-
|
|
37
|
+
mpoRange,
|
|
28
38
|
packageJsonName,
|
|
29
39
|
readProvenance,
|
|
30
|
-
validateName,
|
|
31
40
|
writeProvenance,
|
|
32
|
-
type InitAppPiece,
|
|
33
|
-
type InitAppTemplate,
|
|
34
41
|
type MpoProvenance,
|
|
35
|
-
|
|
36
|
-
} from "./
|
|
42
|
+
} from "./provenance";
|
|
43
|
+
import { archiveSubtree, scaffoldToTemp, type TempScaffold } from "./scaffold";
|
|
37
44
|
|
|
38
45
|
export interface UpdateAppOptions {
|
|
39
|
-
/** Skip pnpm install after the merge. */
|
|
40
46
|
skipInstall?: boolean;
|
|
41
47
|
/** Override the semver range written for @multiplatform.one/* deps. */
|
|
42
48
|
version?: string;
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
assumeVersion?: string;
|
|
49
|
-
/** Template recorded by the --assume-version bootstrap (default universal). */
|
|
50
|
-
assumeTemplate?: InitAppTemplate;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function git(projectDir: string, args: string[], env?: Record<string, string>) {
|
|
54
|
-
return spawn("git", args, {
|
|
55
|
-
cwd: projectDir,
|
|
56
|
-
env: env ? { ...process.env, ...env } : undefined,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function gitOut(projectDir: string, args: string[], env?: Record<string, string>) {
|
|
61
|
-
const result = await git(projectDir, args, env);
|
|
62
|
-
return result.stdout.trim();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Import a directory tree into the project's object database and return a
|
|
66
|
-
* root-less commit for it (temp index; the worktree is never touched). */
|
|
67
|
-
async function commitTreeFromDir(
|
|
68
|
-
projectDir: string,
|
|
69
|
-
dir: string,
|
|
70
|
-
message: string,
|
|
71
|
-
parent?: string,
|
|
72
|
-
): Promise<string> {
|
|
73
|
-
const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-index-")), "index");
|
|
74
|
-
const env = { GIT_INDEX_FILE: indexFile, GIT_WORK_TREE: dir };
|
|
75
|
-
await git(projectDir, ["add", "-A", "--force", "--", "."], env);
|
|
76
|
-
const tree = await gitOut(projectDir, ["write-tree"], env);
|
|
77
|
-
const commitArgs = parent
|
|
78
|
-
? ["commit-tree", tree, "-p", parent, "-m", message]
|
|
79
|
-
: ["commit-tree", tree, "-m", message];
|
|
80
|
-
const commit = await gitOut(projectDir, commitArgs);
|
|
81
|
-
rmSync(indexFile, { force: true });
|
|
82
|
-
return commit;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Scaffold a project with a specific published CLI version into tmp and
|
|
86
|
-
* return the generated project dir. Falls back to the local generator when
|
|
87
|
-
* the requested version matches the running CLI (or dlx fails). */
|
|
88
|
-
async function scaffoldBaseline(
|
|
89
|
-
version: string,
|
|
90
|
-
name: string,
|
|
91
|
-
template: ProvenanceTemplate,
|
|
92
|
-
mpoVersion: string,
|
|
93
|
-
pieces: InitAppPiece[],
|
|
94
|
-
): Promise<{ dir: string; cleanup: () => void }> {
|
|
95
|
-
const parent = mkdtempSync(join(tmpdir(), "mpo-update-"));
|
|
96
|
-
const cleanup = () => rmSync(parent, { recursive: true, force: true });
|
|
97
|
-
const current = cliVersion();
|
|
98
|
-
const templateFlag =
|
|
99
|
-
template === "none" ? ["--pieces-only"] : template === "app" ? ["--web"] : ["--universal"];
|
|
100
|
-
// The consumer scaffolder (initApp + templates) shipped in 6.3.0 —
|
|
101
|
-
// earlier CLIs only had the monorepo-clone init and cannot regenerate a
|
|
102
|
-
// scaffold baseline. Pieces-only scaffolds (`mpo init --pieces-only`, the
|
|
103
|
-
// baseline of ADOPTED projects) shipped in 6.6.0 alongside `mpo adopt`;
|
|
104
|
-
// adopted provenance is always written by ≥6.6 CLIs, so the gate only
|
|
105
|
-
// trips on hand-written .mpo.json files.
|
|
106
|
-
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
107
|
-
const scaffoldCapable =
|
|
108
|
-
template === "none"
|
|
109
|
-
? major > 6 || (major === 6 && minor >= 6)
|
|
110
|
-
: major > 6 || (major === 6 && minor >= 3);
|
|
111
|
-
// Composable pieces (--frappe/--keycloak/--gnome/--vscode/--webext)
|
|
112
|
-
// shipped in 6.5.0 — older published CLIs reject the flags. A 6.3/6.4
|
|
113
|
-
// baseline is scaffolded WITHOUT pieces: piece files then appear as
|
|
114
|
-
// ours-only additions relative to that base, which three-way merge keeps
|
|
115
|
-
// cleanly (they can't have come from those CLIs in the first place).
|
|
116
|
-
const piecesCapable = major > 6 || (major === 6 && minor >= 5);
|
|
117
|
-
const pieceFlags = piecesCapable ? pieces.map((piece) => `--${piece}`) : [];
|
|
118
|
-
if (pieces.length && scaffoldCapable && !piecesCapable && version !== current) {
|
|
119
|
-
console.warn(
|
|
120
|
-
`⚠️ @multiplatform.one/cli@${version} predates composable pieces (6.5.0); ` +
|
|
121
|
-
"the merge base is scaffolded without them.",
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
if (!scaffoldCapable) {
|
|
125
|
-
// The local generator is exact for the running version — the capability
|
|
126
|
-
// gate only matters when an OLDER published CLI must be dlx'd.
|
|
127
|
-
if (version !== current) {
|
|
128
|
-
console.warn(
|
|
129
|
-
template === "none"
|
|
130
|
-
? `⚠️ @multiplatform.one/cli@${version} predates pieces-only baselines (6.6.0); ` +
|
|
131
|
-
"using the current CLI's piece templates as the merge base " +
|
|
132
|
-
"(the merge degrades to two-way: template changes since that version won't land)."
|
|
133
|
-
: `⚠️ @multiplatform.one/cli@${version} predates the consumer scaffolder (6.3.0); ` +
|
|
134
|
-
"using the current CLI's template as the merge base.",
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
} else if (version !== current) {
|
|
138
|
-
// Older published CLIs reject flags they don't know (--skip-git), so
|
|
139
|
-
// only the stable flag set is passed; a scaffold-side git repo is
|
|
140
|
-
// harmless (index operations never descend into nested .git dirs).
|
|
141
|
-
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
142
|
-
try {
|
|
143
|
-
await spawn(
|
|
144
|
-
"pnpm",
|
|
145
|
-
[
|
|
146
|
-
"--package",
|
|
147
|
-
`@multiplatform.one/cli@${version}`,
|
|
148
|
-
"dlx",
|
|
149
|
-
"mpo",
|
|
150
|
-
"init",
|
|
151
|
-
name,
|
|
152
|
-
"--yes",
|
|
153
|
-
"--skip-install",
|
|
154
|
-
"--mpo-version",
|
|
155
|
-
mpoVersion,
|
|
156
|
-
...templateFlag,
|
|
157
|
-
...pieceFlags,
|
|
158
|
-
],
|
|
159
|
-
{ cwd: parent, stdio: "inherit" },
|
|
160
|
-
);
|
|
161
|
-
const dir = join(parent, name);
|
|
162
|
-
// Pieces-only trees may not contain a root package.json (not every
|
|
163
|
-
// piece ships a root fragment) — provenance is their reliable marker.
|
|
164
|
-
const marker = template === "none" ? PROVENANCE_FILE : "package.json";
|
|
165
|
-
if (existsSync(join(dir, marker))) return { dir, cleanup };
|
|
166
|
-
console.warn(`⚠️ dlx scaffold for @multiplatform.one/cli@${version} produced no project`);
|
|
167
|
-
break;
|
|
168
|
-
} catch {
|
|
169
|
-
rmSync(join(parent, name), { recursive: true, force: true });
|
|
170
|
-
if (attempt === 2) {
|
|
171
|
-
console.warn(
|
|
172
|
-
`⚠️ could not scaffold baseline with @multiplatform.one/cli@${version} (network?);`,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
console.warn(" falling back to the current CLI's template as the merge base.");
|
|
178
|
-
}
|
|
179
|
-
const previousCwd = process.cwd();
|
|
180
|
-
process.chdir(parent);
|
|
181
|
-
try {
|
|
182
|
-
await initApp(name, {
|
|
183
|
-
skipInstall: true,
|
|
184
|
-
skipGit: true,
|
|
185
|
-
yes: true,
|
|
186
|
-
template,
|
|
187
|
-
version: mpoVersion,
|
|
188
|
-
pieces,
|
|
189
|
-
});
|
|
190
|
-
} finally {
|
|
191
|
-
process.chdir(previousCwd);
|
|
192
|
-
}
|
|
193
|
-
return { dir: join(parent, name), cleanup };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Return a tree derived from `tree` where every path matching `pins` is
|
|
197
|
-
* replaced by its content in `headCommit` (removed when absent there).
|
|
198
|
-
* Pure index plumbing — the worktree is untouched. */
|
|
199
|
-
async function pinPathsToHead(
|
|
200
|
-
projectDir: string,
|
|
201
|
-
tree: string,
|
|
202
|
-
headCommit: string,
|
|
203
|
-
pins: string[],
|
|
204
|
-
): Promise<string> {
|
|
205
|
-
const indexFile = join(mkdtempSync(join(tmpdir(), "mpo-pin-")), "index");
|
|
206
|
-
const env = { GIT_INDEX_FILE: indexFile };
|
|
207
|
-
await git(projectDir, ["read-tree", tree], env);
|
|
208
|
-
for (const pin of pins) {
|
|
209
|
-
// Paths under the pin in the merged tree (to drop when HEAD lacks them).
|
|
210
|
-
const inTree = await gitOut(projectDir, ["ls-tree", "-r", "--name-only", tree, "--", pin], env)
|
|
211
|
-
.then((out) => out.split("\n").filter(Boolean))
|
|
212
|
-
.catch(() => [] as string[]);
|
|
213
|
-
for (const path of inTree) {
|
|
214
|
-
await git(projectDir, ["update-index", "--force-remove", "--", path], env).catch(() => {});
|
|
215
|
-
}
|
|
216
|
-
// HEAD's entries for the pin (restored verbatim).
|
|
217
|
-
const headEntries = await gitOut(
|
|
218
|
-
projectDir,
|
|
219
|
-
["ls-tree", "-r", headCommit, "--", pin],
|
|
220
|
-
env,
|
|
221
|
-
).catch(() => "");
|
|
222
|
-
for (const line of headEntries.split("\n").filter(Boolean)) {
|
|
223
|
-
// "<mode> blob <oid>\t<path>"
|
|
224
|
-
const match = line.match(/^(\d+) \S+ ([0-9a-f]+)\t(.+)$/);
|
|
225
|
-
if (!match) continue;
|
|
226
|
-
await git(
|
|
227
|
-
projectDir,
|
|
228
|
-
["update-index", "--add", "--cacheinfo", `${match[1]},${match[2]},${match[3]}`],
|
|
229
|
-
env,
|
|
230
|
-
).catch(() => {});
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
const pinned = await gitOut(projectDir, ["write-tree"], env);
|
|
234
|
-
rmSync(indexFile, { force: true });
|
|
235
|
-
return pinned;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// ── package.json conflict auto-resolution ─────────────────────────
|
|
239
|
-
//
|
|
240
|
-
// The dominant conflict class in real updates is dependency-range drift:
|
|
241
|
-
// the project bumped a range between updates (ours ^6.4.2) while the new
|
|
242
|
-
// template carries its own (theirs ^6.4.3). Both sides changed the same
|
|
243
|
-
// JSON entry, so textual merge punts — but the resolution is mechanical:
|
|
244
|
-
// take the newer minimum version. A structural three-way merge handles it.
|
|
245
|
-
|
|
246
|
-
const UNRESOLVED = Symbol("unresolved");
|
|
247
|
-
/** Deleted-on-one-side sentinel (distinct from a key that never existed). */
|
|
248
|
-
const ABSENT = Symbol("absent");
|
|
249
|
-
|
|
250
|
-
const DEP_MAP_KEYS = new Set([
|
|
251
|
-
"dependencies",
|
|
252
|
-
"devDependencies",
|
|
253
|
-
"peerDependencies",
|
|
254
|
-
"optionalDependencies",
|
|
255
|
-
"overrides",
|
|
256
|
-
"resolutions",
|
|
257
|
-
]);
|
|
258
|
-
|
|
259
|
-
/** Minimum concrete version of a simple range (`^x.y.z`, `~x.y.z`,
|
|
260
|
-
* `x.y.z`, optional prerelease). Anything fancier is not auto-resolved. */
|
|
261
|
-
function rangeMinimum(range: unknown): [number, number, number] | undefined {
|
|
262
|
-
if (typeof range !== "string") return undefined;
|
|
263
|
-
const match = range.match(/^[\^~]?(\d+)\.(\d+)\.(\d+)(?:-[\w.]+)?$/);
|
|
264
|
-
if (!match) return undefined;
|
|
265
|
-
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
269
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
/** Path or url to update from (default: the CLI's recorded source). */
|
|
50
|
+
source?: string;
|
|
51
|
+
/** Commit, tag or branch at that source (default: the CLI's source commit). */
|
|
52
|
+
checkout?: string;
|
|
53
|
+
verbose?: boolean;
|
|
270
54
|
}
|
|
271
55
|
|
|
272
|
-
function
|
|
273
|
-
return
|
|
56
|
+
function isSha(ref: string): boolean {
|
|
57
|
+
return /^[0-9a-f]{40}$/.test(ref);
|
|
274
58
|
}
|
|
275
59
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
// Only one side diverged from base → that side wins (undefined = deletion).
|
|
284
|
-
if (jsonEqual(theirs, base)) return ours === undefined ? ABSENT : ours;
|
|
285
|
-
if (jsonEqual(ours, base)) return theirs === undefined ? ABSENT : theirs;
|
|
286
|
-
// Both diverged. Objects merge key-by-key; dep ranges take the newer
|
|
287
|
-
// minimum; everything else is a genuine conflict.
|
|
288
|
-
if (isPlainObject(ours) && isPlainObject(theirs)) {
|
|
289
|
-
const baseObject = isPlainObject(base) ? base : {};
|
|
290
|
-
const merged: Record<string, unknown> = {};
|
|
291
|
-
const keys = [...Object.keys(ours), ...Object.keys(theirs).filter((key) => !(key in ours))];
|
|
292
|
-
for (const key of keys) {
|
|
293
|
-
const child = mergeNode(
|
|
294
|
-
baseObject[key],
|
|
295
|
-
ours[key],
|
|
296
|
-
theirs[key],
|
|
297
|
-
inDepMap || DEP_MAP_KEYS.has(key),
|
|
298
|
-
);
|
|
299
|
-
if (child === UNRESOLVED) return UNRESOLVED;
|
|
300
|
-
if (child !== ABSENT) merged[key] = child;
|
|
301
|
-
}
|
|
302
|
-
return merged;
|
|
303
|
-
}
|
|
304
|
-
if (inDepMap) {
|
|
305
|
-
const oursMin = rangeMinimum(ours);
|
|
306
|
-
const theirsMin = rangeMinimum(theirs);
|
|
307
|
-
if (oursMin && theirsMin) {
|
|
308
|
-
for (let i = 0; i < 3; i++) {
|
|
309
|
-
if (oursMin[i]! > theirsMin[i]!) return ours;
|
|
310
|
-
if (oursMin[i]! < theirsMin[i]!) return theirs;
|
|
311
|
-
}
|
|
312
|
-
return theirs; // equal minimums, different spelling — template wins
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
return UNRESOLVED;
|
|
60
|
+
/** The commit a source spec names, when it can be known without scaffolding. */
|
|
61
|
+
async function resolveSha(spec: SourceSpec): Promise<string | undefined> {
|
|
62
|
+
if (isSha(spec.ref)) return spec.ref;
|
|
63
|
+
if (isGitUrl(spec.source)) return undefined;
|
|
64
|
+
return gitOut(spec.source, ["rev-parse", "--verify", `${spec.ref}^{commit}`]).catch(
|
|
65
|
+
() => undefined,
|
|
66
|
+
);
|
|
316
67
|
}
|
|
317
68
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
try {
|
|
330
|
-
baseJson = base === undefined ? {} : JSON.parse(base);
|
|
331
|
-
oursJson = JSON.parse(ours);
|
|
332
|
-
theirsJson = JSON.parse(theirs);
|
|
333
|
-
} catch {
|
|
334
|
-
return undefined;
|
|
335
|
-
}
|
|
336
|
-
if (!isPlainObject(oursJson) || !isPlainObject(theirsJson)) return undefined;
|
|
337
|
-
const merged = mergeNode(baseJson, oursJson, theirsJson, false);
|
|
338
|
-
if (merged === UNRESOLVED || merged === ABSENT || !isPlainObject(merged)) return undefined;
|
|
339
|
-
return merged;
|
|
69
|
+
// pnpm-lock.yaml stays pinned to HEAD through the merge, so a merge that
|
|
70
|
+
// removes .pnpmfile.cjs leaves its checksum behind, and pnpm re-resolves every
|
|
71
|
+
// dependency over the mismatch instead of keeping the lockfile's pins.
|
|
72
|
+
async function dropStalePnpmfileChecksum(projectDir: string): Promise<void> {
|
|
73
|
+
const lockfile = join(projectDir, "pnpm-lock.yaml");
|
|
74
|
+
if (existsSync(join(projectDir, ".pnpmfile.cjs")) || !existsSync(lockfile)) return;
|
|
75
|
+
const lock = readFileSync(lockfile, "utf-8");
|
|
76
|
+
const stripped = lock.replace(/^pnpmfileChecksum: .*\n\n?/m, "");
|
|
77
|
+
if (stripped === lock) return;
|
|
78
|
+
writeFileSync(lockfile, stripped);
|
|
79
|
+
await git(projectDir, ["add", "--", "pnpm-lock.yaml"]);
|
|
340
80
|
}
|
|
341
81
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
projectDir: string,
|
|
346
|
-
mergedTree: string,
|
|
347
|
-
conflicts: string[],
|
|
348
|
-
baseCommit: string,
|
|
349
|
-
headCommit: string,
|
|
350
|
-
nextCommit: string,
|
|
351
|
-
): Promise<{ tree: string; conflicts: string[]; resolved: string[] }> {
|
|
352
|
-
const candidates = conflicts.filter((file) => basename(file) === "package.json");
|
|
353
|
-
if (!candidates.length) return { tree: mergedTree, conflicts, resolved: [] };
|
|
354
|
-
|
|
355
|
-
const scratch = mkdtempSync(join(tmpdir(), "mpo-resolve-"));
|
|
356
|
-
const resolved: { path: string; oid: string }[] = [];
|
|
357
|
-
try {
|
|
358
|
-
for (const file of candidates) {
|
|
359
|
-
const show = (commit: string) =>
|
|
360
|
-
gitOut(projectDir, ["show", `${commit}:${file}`]).catch(() => undefined);
|
|
361
|
-
const [base, ours, theirs] = await Promise.all([
|
|
362
|
-
show(baseCommit),
|
|
363
|
-
show(headCommit),
|
|
364
|
-
show(nextCommit),
|
|
365
|
-
]);
|
|
366
|
-
// Add/delete conflicts stay manual — only both-sides-present merges.
|
|
367
|
-
if (ours === undefined || theirs === undefined) continue;
|
|
368
|
-
const merged = threeWayMergePackageJson(base, ours, theirs);
|
|
369
|
-
if (!merged) continue;
|
|
370
|
-
const blobFile = join(scratch, "blob.json");
|
|
371
|
-
writeFileSync(blobFile, `${JSON.stringify(merged, null, 2)}\n`);
|
|
372
|
-
const oid = await gitOut(projectDir, ["hash-object", "-w", "--", blobFile]);
|
|
373
|
-
resolved.push({ path: file, oid });
|
|
374
|
-
}
|
|
375
|
-
if (!resolved.length) return { tree: mergedTree, conflicts, resolved: [] };
|
|
376
|
-
|
|
377
|
-
const indexFile = join(scratch, "index");
|
|
378
|
-
const env = { GIT_INDEX_FILE: indexFile };
|
|
379
|
-
await git(projectDir, ["read-tree", mergedTree], env);
|
|
380
|
-
for (const entry of resolved) {
|
|
381
|
-
await git(
|
|
382
|
-
projectDir,
|
|
383
|
-
["update-index", "--add", "--cacheinfo", `100644,${entry.oid},${entry.path}`],
|
|
384
|
-
env,
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
const tree = await gitOut(projectDir, ["write-tree"], env);
|
|
388
|
-
const resolvedPaths = new Set(resolved.map((entry) => entry.path));
|
|
389
|
-
return {
|
|
390
|
-
tree,
|
|
391
|
-
conflicts: conflicts.filter((file) => !resolvedPaths.has(file)),
|
|
392
|
-
resolved: [...resolvedPaths],
|
|
393
|
-
};
|
|
394
|
-
} finally {
|
|
395
|
-
rmSync(scratch, { recursive: true, force: true });
|
|
396
|
-
}
|
|
82
|
+
function tempDir(): { dir: string; cleanup: () => void } {
|
|
83
|
+
const dir = mkdtempSync(join(tmpdir(), "mpo-update-"));
|
|
84
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
397
85
|
}
|
|
398
86
|
|
|
399
87
|
/**
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* the project; provenance is written and committed, then the normal update
|
|
404
|
-
* proceeds against that baseline.
|
|
88
|
+
* templates/pieces as they were at another CLI version: the monorepo tag
|
|
89
|
+
* v<version>, from the CLI's source (or --source). Falls back to the
|
|
90
|
+
* current templates with a warning, which degrades that merge to two-way.
|
|
405
91
|
*/
|
|
406
|
-
async function
|
|
407
|
-
|
|
92
|
+
async function piecesRootAt(
|
|
93
|
+
version: string,
|
|
408
94
|
options: UpdateAppOptions,
|
|
409
|
-
): Promise<
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
95
|
+
): Promise<{ root?: string; cleanup: () => void }> {
|
|
96
|
+
if (version === cliVersion()) return { cleanup: () => {} };
|
|
97
|
+
const scratch = tempDir();
|
|
98
|
+
const source = options.source ?? monorepoCheckout() ?? monorepoUrl;
|
|
99
|
+
try {
|
|
100
|
+
await archiveSubtree(source, `v${version}`, "public/cli/templates/pieces", scratch.dir);
|
|
101
|
+
return { root: join(scratch.dir, "public/cli/templates/pieces"), cleanup: scratch.cleanup };
|
|
102
|
+
} catch {
|
|
103
|
+
scratch.cleanup();
|
|
104
|
+
console.warn(
|
|
105
|
+
`⚠️ could not read templates/pieces at v${version} from ${source}; ` +
|
|
106
|
+
"using the current pieces as the merge base (changes since that version won't land).",
|
|
419
107
|
);
|
|
108
|
+
return { cleanup: () => {} };
|
|
420
109
|
}
|
|
421
|
-
const provenance: MpoProvenance = {
|
|
422
|
-
template: options.assumeTemplate ?? "universal",
|
|
423
|
-
cliVersion: version,
|
|
424
|
-
name: validateName(rawName),
|
|
425
|
-
mpoVersion: `^${version}`,
|
|
426
|
-
pieces: [],
|
|
427
|
-
};
|
|
428
|
-
console.log(
|
|
429
|
-
`\nBootstrapping provenance: assuming this project was scaffolded by ` +
|
|
430
|
-
`cli@${version} (${provenance.template} template).`,
|
|
431
|
-
);
|
|
432
|
-
writeProvenance(projectDir, provenance);
|
|
433
|
-
await git(projectDir, ["add", "--", PROVENANCE_FILE]);
|
|
434
|
-
await git(projectDir, [
|
|
435
|
-
"commit",
|
|
436
|
-
"--quiet",
|
|
437
|
-
"-m",
|
|
438
|
-
`chore: record assumed mpo scaffold provenance (cli@${version})`,
|
|
439
|
-
]).catch(() => {
|
|
440
|
-
console.warn("⚠️ git commit failed (missing git identity?) — provenance left staged.");
|
|
441
|
-
});
|
|
442
|
-
return provenance;
|
|
443
110
|
}
|
|
444
111
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
// Clean tree required — the merge lands in the worktree.
|
|
451
|
-
await git(projectDir, ["diff", "--quiet"]).catch(() => {
|
|
452
|
-
throw new Error("mpo update requires a clean working tree (unstaged changes present)");
|
|
453
|
-
});
|
|
454
|
-
await git(projectDir, ["diff", "--cached", "--quiet"]).catch(() => {
|
|
455
|
-
throw new Error("mpo update requires a clean working tree (staged changes present)");
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
let provenance = readProvenance(projectDir);
|
|
459
|
-
if (options.assumeVersion) {
|
|
460
|
-
if (provenance) {
|
|
461
|
-
throw new Error(
|
|
462
|
-
`--assume-version is for projects without ${PROVENANCE_FILE} — ` +
|
|
463
|
-
`this project already has provenance (cli@${provenance.cliVersion})`,
|
|
464
|
-
);
|
|
465
|
-
}
|
|
466
|
-
provenance = await bootstrapProvenance(projectDir, options);
|
|
467
|
-
}
|
|
468
|
-
if (!provenance) {
|
|
469
|
-
throw new Error(
|
|
470
|
-
`${PROVENANCE_FILE} not found — this project predates scaffold provenance.\n` +
|
|
471
|
-
" - scaffolded by an old CLI (pre-6.3)? bootstrap it:\n" +
|
|
472
|
-
" mpo update --assume-version <cliVersion> [--template universal|app]\n" +
|
|
473
|
-
" - never scaffolded (hand-adopted pieces)? bring it under management:\n" +
|
|
474
|
-
" mpo adopt --<piece> [...]",
|
|
475
|
-
);
|
|
476
|
-
}
|
|
477
|
-
|
|
112
|
+
async function updateAdopted(
|
|
113
|
+
projectDir: string,
|
|
114
|
+
provenance: MpoProvenance,
|
|
115
|
+
options: UpdateAppOptions,
|
|
116
|
+
): Promise<void> {
|
|
478
117
|
const currentVersion = cliVersion() ?? "0.0.0";
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const adopted = provenance.template === "none";
|
|
486
|
-
const firstReconcile = adopted && provenance.reconciled === false;
|
|
487
|
-
|
|
488
|
-
console.log(`\nmpo update: ${provenance.cliVersion} → ${currentVersion}`);
|
|
489
|
-
console.log(` template: ${provenance.template}${adopted ? " (adopted)" : ""}`);
|
|
490
|
-
if (provenance.pieces.length) console.log(` pieces: ${provenance.pieces.join(", ")}`);
|
|
491
|
-
console.log(` project: ${provenance.name}\n`);
|
|
118
|
+
const targetRange = mpoRange(options.version, currentVersion);
|
|
119
|
+
const name = provenance.name!;
|
|
120
|
+
const firstReconcile = provenance.reconciled === false;
|
|
121
|
+
console.log(`\nmpo update (adopted): cli ${provenance.cliVersion} → ${currentVersion}`);
|
|
122
|
+
console.log(` pieces ${provenance.pieces.join(", ")}`);
|
|
123
|
+
console.log(` project ${name}\n`);
|
|
492
124
|
if (firstReconcile) {
|
|
493
125
|
console.log("⚠️ FIRST UPDATE AFTER ADOPTION: piece files are reconciled against the");
|
|
494
126
|
console.log(" piece templates from an empty merge base. Diffs and conflicts on");
|
|
@@ -496,238 +128,193 @@ export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
|
|
|
496
128
|
console.log(" commit. Later updates merge three-way and stay quiet.\n");
|
|
497
129
|
}
|
|
498
130
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
// so its honest merge base is the empty tree.
|
|
504
|
-
let base: { dir: string; cleanup: () => void };
|
|
505
|
-
if (firstReconcile) {
|
|
506
|
-
console.log("Using an empty merge base (first update after adoption)...");
|
|
507
|
-
const dir = mkdtempSync(join(tmpdir(), "mpo-update-"));
|
|
508
|
-
base = { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
509
|
-
} else {
|
|
510
|
-
console.log(`Scaffolding merge base (cli@${provenance.cliVersion})...`);
|
|
511
|
-
base = await scaffoldBaseline(
|
|
512
|
-
provenance.cliVersion,
|
|
513
|
-
provenance.name,
|
|
514
|
-
provenance.template,
|
|
515
|
-
provenance.mpoVersion,
|
|
516
|
-
provenance.pieces,
|
|
517
|
-
);
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// 2. Generate the CURRENT scaffold (theirs).
|
|
521
|
-
console.log(`Scaffolding update target (cli@${currentVersion})...`);
|
|
522
|
-
const next = await scaffoldBaseline(
|
|
523
|
-
currentVersion,
|
|
524
|
-
provenance.name,
|
|
525
|
-
provenance.template,
|
|
526
|
-
targetMpoRange,
|
|
527
|
-
provenance.pieces,
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
// .mpo.json is TOOL-owned, never merged: pre-provenance CLIs (≤6.3.0)
|
|
531
|
-
// didn't write it, so leaving it in the scaffolds guarantees an add/add
|
|
532
|
-
// phantom conflict on every honest old-baseline update. It is stripped
|
|
533
|
-
// from both synthetic trees and written authoritatively after the merge.
|
|
534
|
-
rmSync(join(base.dir, ".mpo.json"), { force: true });
|
|
535
|
-
rmSync(join(next.dir, ".mpo.json"), { force: true });
|
|
536
|
-
|
|
131
|
+
const base = tempDir();
|
|
132
|
+
const theirs = tempDir();
|
|
133
|
+
let oldPieces: { root?: string; cleanup: () => void } = { cleanup: () => {} };
|
|
134
|
+
let pieces = provenance.pieces;
|
|
537
135
|
try {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
baseCommit,
|
|
550
|
-
);
|
|
551
|
-
|
|
552
|
-
let mergedTree: string;
|
|
553
|
-
let conflicts: string[] = [];
|
|
554
|
-
try {
|
|
555
|
-
const out = await gitOut(projectDir, [
|
|
556
|
-
"merge-tree",
|
|
557
|
-
"--write-tree",
|
|
558
|
-
"--name-only",
|
|
559
|
-
`--merge-base=${baseCommit}`,
|
|
560
|
-
head,
|
|
561
|
-
nextCommit,
|
|
562
|
-
]);
|
|
563
|
-
mergedTree = out.split("\n")[0]!.trim();
|
|
564
|
-
} catch (err) {
|
|
565
|
-
// Exit code 1 = conflicts. Output format: <tree>\n<conflicted file
|
|
566
|
-
// names, one per line>\n\n<informational messages> — only the names
|
|
567
|
-
// section (up to the blank line) lists conflicted paths.
|
|
568
|
-
const stdout = (err as { stdout?: string }).stdout ?? "";
|
|
569
|
-
const lines = stdout.split("\n");
|
|
570
|
-
if (!lines[0]?.trim()) throw err;
|
|
571
|
-
mergedTree = lines[0].trim();
|
|
572
|
-
const blank = lines.indexOf("", 1);
|
|
573
|
-
conflicts = lines
|
|
574
|
-
.slice(1, blank === -1 ? undefined : blank)
|
|
575
|
-
.map((line) => line.trim())
|
|
576
|
-
.filter(Boolean);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// The merged .mpo.json can interleave ours/theirs lines (add/add
|
|
580
|
-
// two-way merge) — the post-merge provenance is always written
|
|
581
|
-
// authoritatively below. `reconciled: false` is intentionally NOT
|
|
582
|
-
// carried over: a successful update reconciles the adopted project,
|
|
583
|
-
// so later updates scaffold their merge base from the recorded version.
|
|
584
|
-
const finalProvenance: MpoProvenance = {
|
|
585
|
-
template: provenance.template,
|
|
586
|
-
cliVersion: currentVersion,
|
|
587
|
-
name: provenance.name,
|
|
588
|
-
mpoVersion: targetMpoRange,
|
|
589
|
-
pieces: provenance.pieces,
|
|
590
|
-
};
|
|
591
|
-
|
|
592
|
-
// .updateignore entries (+ pnpm-lock.yaml) pin to the pre-update HEAD.
|
|
593
|
-
// Pins are applied to the MERGED TREE before committing, so a clean
|
|
594
|
-
// update never leaves the tree dirty.
|
|
595
|
-
//
|
|
596
|
-
// Entries are EXACT FILES or DIRECTORY PREFIXES — the pin machinery
|
|
597
|
-
// rides `git ls-tree -- <path>`, which does prefix matching, not
|
|
598
|
-
// fnmatch. A silently dead ignore rule is the worst failure mode, so
|
|
599
|
-
// the one natural glob spelling (`dir/**`, `dir/*`) is normalized to
|
|
600
|
-
// its directory and anything else glob-shaped is refused loudly.
|
|
601
|
-
const pins = ["pnpm-lock.yaml"];
|
|
602
|
-
const updateignore = join(projectDir, ".updateignore");
|
|
603
|
-
if (existsSync(updateignore)) {
|
|
604
|
-
for (const line of readFileSync(updateignore, "utf-8").split("\n")) {
|
|
605
|
-
const pattern = line.trim();
|
|
606
|
-
if (!pattern || pattern.startsWith("#")) continue;
|
|
607
|
-
const normalized = pattern.replace(/\/\*{1,2}$/, "");
|
|
608
|
-
if (/[*?[\]]/.test(normalized)) {
|
|
609
|
-
throw new Error(
|
|
610
|
-
`.updateignore: "${pattern}" looks like a glob, but entries are ` +
|
|
611
|
-
"exact files or directory prefixes (a glob here would silently " +
|
|
612
|
-
"pin nothing). List the file or directory instead.",
|
|
613
|
-
);
|
|
614
|
-
}
|
|
615
|
-
pins.push(normalized.replace(/\/+$/, ""));
|
|
136
|
+
if (!firstReconcile) {
|
|
137
|
+
oldPieces = await piecesRootAt(provenance.cliVersion, options);
|
|
138
|
+
scaffoldPieces(base.dir, {
|
|
139
|
+
name,
|
|
140
|
+
mpoVersion: provenance.mpoVersion ?? `^${provenance.cliVersion}`,
|
|
141
|
+
pieces: provenance.pieces,
|
|
142
|
+
root: oldPieces.root,
|
|
143
|
+
});
|
|
144
|
+
pieces = withSplitPieces(provenance.pieces, oldPieces.root);
|
|
145
|
+
if (pieces !== provenance.pieces) {
|
|
146
|
+
console.log(" frappe-bench added: the frappe piece no longer carries the dev bench\n");
|
|
616
147
|
}
|
|
617
148
|
}
|
|
618
|
-
|
|
619
|
-
// Pinning resolves any conflict inside a pinned path (the pre-update
|
|
620
|
-
// HEAD content wins by definition).
|
|
621
|
-
conflicts = conflicts.filter(
|
|
622
|
-
(file) => !pins.some((pin) => file === pin || file.startsWith(`${pin}/`)),
|
|
623
|
-
);
|
|
149
|
+
scaffoldPieces(theirs.dir, { name, mpoVersion: targetRange, pieces });
|
|
624
150
|
|
|
625
|
-
|
|
626
|
-
// (newer minimum version wins) — don't punt those to the user.
|
|
627
|
-
const autoResolved = await autoResolveDependencyConflicts(
|
|
151
|
+
const outcome = await mergeScaffolds({
|
|
628
152
|
projectDir,
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
// current template — don't stack empty merge commits.
|
|
643
|
-
const headTree = await gitOut(projectDir, ["rev-parse", `${head}^{tree}`]);
|
|
644
|
-
if (!conflicts.length && mergedTree === headTree) {
|
|
153
|
+
baseDir: firstReconcile ? undefined : base.dir,
|
|
154
|
+
theirsDir: theirs.dir,
|
|
155
|
+
baseLabel: `mpo pieces ${provenance.cliVersion}`,
|
|
156
|
+
theirsLabel: `mpo pieces ${currentVersion}`,
|
|
157
|
+
});
|
|
158
|
+
const next: MpoProvenance = {
|
|
159
|
+
...provenance,
|
|
160
|
+
cliVersion: currentVersion,
|
|
161
|
+
mpoVersion: targetRange,
|
|
162
|
+
pieces,
|
|
163
|
+
reconciled: undefined,
|
|
164
|
+
};
|
|
165
|
+
if (!outcome.changed && !outcome.conflicts.length) {
|
|
645
166
|
if (
|
|
646
|
-
provenance.cliVersion !==
|
|
647
|
-
provenance.mpoVersion !==
|
|
648
|
-
provenance.
|
|
167
|
+
provenance.cliVersion !== currentVersion ||
|
|
168
|
+
provenance.mpoVersion !== targetRange ||
|
|
169
|
+
pieces !== provenance.pieces ||
|
|
170
|
+
firstReconcile
|
|
649
171
|
) {
|
|
650
|
-
writeProvenance(projectDir,
|
|
651
|
-
|
|
652
|
-
await git(projectDir, [
|
|
653
|
-
"commit",
|
|
654
|
-
"--quiet",
|
|
655
|
-
"-m",
|
|
656
|
-
`chore: record mpo template provenance ${currentVersion}`,
|
|
657
|
-
]);
|
|
658
|
-
console.log("\n✅ already up to date (provenance refreshed)");
|
|
172
|
+
writeProvenance(projectDir, next);
|
|
173
|
+
console.log(`\n✅ already up to date; ${PROVENANCE_FILE} refreshed — commit it`);
|
|
659
174
|
} else {
|
|
660
175
|
console.log("\n✅ already up to date");
|
|
661
176
|
}
|
|
662
177
|
return;
|
|
663
178
|
}
|
|
179
|
+
await landTree(projectDir, outcome.mergedTree);
|
|
180
|
+
writeProvenance(projectDir, next);
|
|
181
|
+
await finishMerge(
|
|
182
|
+
projectDir,
|
|
183
|
+
outcome,
|
|
184
|
+
`chore: mpo update ${provenance.cliVersion} → ${currentVersion}`,
|
|
185
|
+
options,
|
|
186
|
+
);
|
|
187
|
+
} finally {
|
|
188
|
+
base.cleanup();
|
|
189
|
+
theirs.cleanup();
|
|
190
|
+
oldPieces.cleanup();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
664
193
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
]);
|
|
678
|
-
await git(projectDir, ["update-ref", "HEAD", mergeCommit]);
|
|
679
|
-
await git(projectDir, ["reset", "--hard", "HEAD"]);
|
|
680
|
-
// Tool-owned outputs belong to the update commit: provenance + the
|
|
681
|
-
// regenerated .vscode config.
|
|
682
|
-
writeProvenance(projectDir, finalProvenance);
|
|
683
|
-
try {
|
|
684
|
-
await generateVscodeConfig(projectDir);
|
|
685
|
-
} catch {
|
|
686
|
-
// best-effort (older layouts)
|
|
687
|
-
}
|
|
688
|
-
await git(projectDir, ["add", "--", ".mpo.json", ".vscode"]);
|
|
689
|
-
await git(projectDir, ["commit", "--amend", "--no-edit", "--quiet"]);
|
|
690
|
-
cleanMergeMessage = message;
|
|
691
|
-
} else {
|
|
692
|
-
// Land the merged tree (with conflict markers) in index + worktree for
|
|
693
|
-
// manual resolution; pins are already part of the tree.
|
|
694
|
-
await git(projectDir, ["read-tree", "-u", "--reset", mergedTree]);
|
|
695
|
-
writeProvenance(projectDir, finalProvenance);
|
|
696
|
-
console.log("\n⚠️ merge conflicts — resolve the markers, then commit:");
|
|
697
|
-
for (const file of conflicts) console.log(` ${file}`);
|
|
698
|
-
console.log(
|
|
699
|
-
`\n git add -A && git commit -m "chore: mpo update ${provenance.cliVersion} → ${currentVersion}"`,
|
|
700
|
-
);
|
|
701
|
-
}
|
|
194
|
+
async function updateClone(
|
|
195
|
+
projectDir: string,
|
|
196
|
+
provenance: MpoProvenance,
|
|
197
|
+
options: UpdateAppOptions,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
const currentVersion = cliVersion() ?? "0.0.0";
|
|
200
|
+
const recorded = provenance.source;
|
|
201
|
+
const templateEra = !recorded;
|
|
202
|
+
const apps = provenance.apps;
|
|
203
|
+
if (!apps.length) {
|
|
204
|
+
throw new Error(`${PROVENANCE_FILE} lists no product apps — nothing to update`);
|
|
205
|
+
}
|
|
702
206
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
207
|
+
// theirs: the CLI's source commit unless --source/--checkout say otherwise.
|
|
208
|
+
const nextSpec = resolveSource({ source: options.source, checkout: options.checkout });
|
|
209
|
+
if (nextSpec.warning) console.warn(`⚠️ ${nextSpec.warning}`);
|
|
210
|
+
// base: the recorded commit, read from --source when given (a mirror or a
|
|
211
|
+
// local clone must hold that commit too).
|
|
212
|
+
const baseSource = options.source ?? recorded?.url ?? nextSpec.source;
|
|
213
|
+
const targetRange = options.version ? mpoRange(options.version) : undefined;
|
|
214
|
+
|
|
215
|
+
console.log(
|
|
216
|
+
`\nmpo update: ${recorded ? recorded.sha.slice(0, 12) : "(template-era project)"} → ${nextSpec.source} @ ${nextSpec.ref}`,
|
|
217
|
+
);
|
|
218
|
+
console.log(` apps ${apps.join(", ")}`);
|
|
219
|
+
console.log(
|
|
220
|
+
` services ${provenance.services.length ? provenance.services.join(", ") : "(none)"}`,
|
|
221
|
+
);
|
|
222
|
+
if (templateEra) {
|
|
223
|
+
console.log("\n⚠️ This project came from the retired template scaffolder, so there is no");
|
|
224
|
+
console.log(" scaffold to use as a merge base. The monorepo's One app is merged in");
|
|
225
|
+
console.log(" from an empty base once: expect additions and conflicts on shared files.");
|
|
226
|
+
console.log(" Pin what is yours in .updateignore first if needed. After this update the");
|
|
227
|
+
console.log(` project is clone-derived and later updates merge three-way.\n`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (recorded && !options.version) {
|
|
231
|
+
const nextSha = await resolveSha(nextSpec);
|
|
232
|
+
if (nextSha === recorded.sha) {
|
|
233
|
+
console.log("\n✅ already up to date");
|
|
234
|
+
return;
|
|
721
235
|
}
|
|
236
|
+
}
|
|
722
237
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
238
|
+
// Both scaffolds carry the project's own root package name, so the name
|
|
239
|
+
// never reads as a change (an empty base would otherwise conflict on it).
|
|
240
|
+
const name = packageJsonName(projectDir) ?? basename(projectDir);
|
|
241
|
+
const theirs = await scaffoldToTemp({
|
|
242
|
+
source: nextSpec.source,
|
|
243
|
+
ref: nextSpec.ref,
|
|
244
|
+
apps,
|
|
245
|
+
services: provenance.services,
|
|
246
|
+
mpoVersion: targetRange,
|
|
247
|
+
name,
|
|
248
|
+
verbose: options.verbose,
|
|
249
|
+
});
|
|
250
|
+
let base: TempScaffold | undefined;
|
|
251
|
+
try {
|
|
252
|
+
const nextSha = theirs.provenance.source!.sha;
|
|
253
|
+
if (recorded && nextSha === recorded.sha && !options.version) {
|
|
254
|
+
console.log("\n✅ already up to date");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (recorded) {
|
|
258
|
+
base = await scaffoldToTemp({
|
|
259
|
+
source: baseSource,
|
|
260
|
+
ref: recorded.sha,
|
|
261
|
+
apps,
|
|
262
|
+
services: provenance.services,
|
|
263
|
+
mpoVersion: `^${provenance.cliVersion}`,
|
|
264
|
+
name,
|
|
265
|
+
verbose: options.verbose,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
for (const dir of [theirs.dir, base?.dir]) {
|
|
269
|
+
if (dir) rmSync(join(dir, PROVENANCE_FILE), { force: true });
|
|
270
|
+
}
|
|
271
|
+
const outcome = await mergeScaffolds({
|
|
272
|
+
projectDir,
|
|
273
|
+
baseDir: base?.dir,
|
|
274
|
+
theirsDir: theirs.dir,
|
|
275
|
+
baseLabel: recorded ? `mpo scaffold ${recorded.sha}` : "mpo scaffold (empty base)",
|
|
276
|
+
theirsLabel: `mpo scaffold ${nextSha}`,
|
|
277
|
+
});
|
|
278
|
+
const next: MpoProvenance = {
|
|
279
|
+
cliVersion: theirs.provenance.cliVersion || currentVersion,
|
|
280
|
+
source: { sha: nextSha, url: options.source ?? recorded?.url ?? nextSpec.source },
|
|
281
|
+
apps,
|
|
282
|
+
services: provenance.services,
|
|
283
|
+
pieces: [],
|
|
284
|
+
};
|
|
285
|
+
const label = `${recorded ? recorded.sha.slice(0, 12) : "template"} → ${nextSha.slice(0, 12)}`;
|
|
286
|
+
if (!outcome.changed && !outcome.conflicts.length) {
|
|
287
|
+
writeProvenance(projectDir, next);
|
|
288
|
+
console.log(`\n✅ already up to date; ${PROVENANCE_FILE} refreshed — commit it`);
|
|
289
|
+
return;
|
|
728
290
|
}
|
|
291
|
+
await landTree(projectDir, outcome.mergedTree);
|
|
292
|
+
await dropStalePnpmfileChecksum(projectDir);
|
|
293
|
+
writeProvenance(projectDir, next);
|
|
294
|
+
await finishMerge(projectDir, outcome, `chore: mpo update ${label}`, options);
|
|
729
295
|
} finally {
|
|
730
|
-
|
|
731
|
-
|
|
296
|
+
theirs.cleanup();
|
|
297
|
+
base?.cleanup();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function updateApp(options: UpdateAppOptions = {}): Promise<void> {
|
|
302
|
+
const projectDir = await projectRootOf("mpo update");
|
|
303
|
+
await requireCleanTree(projectDir, "mpo update");
|
|
304
|
+
const provenance = readProvenance(projectDir);
|
|
305
|
+
if (!provenance) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`${PROVENANCE_FILE} not found — this project has no scaffold provenance.\n` +
|
|
308
|
+
" - hand-copied piece files from the templates? bring it under management:\n" +
|
|
309
|
+
" mpo adopt --<piece> [...]\n" +
|
|
310
|
+
" - scaffolded from the monorepo before provenance existed? write .mpo.json:\n" +
|
|
311
|
+
' { "cliVersion": "<cli>", "source": { "sha": "<commit>", "url": "<repo>" },\n' +
|
|
312
|
+
' "apps": ["<name>"], "services": [], "pieces": [] }',
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
if (provenance.template === "none") {
|
|
316
|
+
await updateAdopted(projectDir, provenance, options);
|
|
317
|
+
return;
|
|
732
318
|
}
|
|
319
|
+
await updateClone(projectDir, provenance, options);
|
|
733
320
|
}
|