Skip to content

Commit bc88929

Browse files
iclantonclaude
andcommitted
Record package manager in package.json engines field
- Remove `lastPackageManager` from `SPFxScaffoldLog`; the selected package manager is now stored in (and read back from) the project's `package.json` `"engines"` field instead of the JSONL scaffold log. - Add `PackageManagerEnginesHelper` to `@microsoft/spfx-template-api`: - `tryReadPackageManagerFromPackageJsonEnginesAsync` reads the package manager from `engines`, throwing if multiple are found. - `writePackageManagerToPackageJsonEnginesAsync` detects the installed version via async spawn and writes a `>=MAJOR` constraint. - Exports `VALID_PACKAGE_MANAGERS` and `PackageManager` type. - Update `CreateAction` to use the new helpers instead of the log. - Add unit tests for `PackageManagerEnginesHelper`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c5d9586 commit bc88929

13 files changed

Lines changed: 468 additions & 110 deletions

File tree

api/spfx-template-api/README.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,6 @@ await writer.writeAsync(templateFs, targetDir, { log });
203203

204204
// Persist back to disk
205205
await log.saveToFolderAsync(targetDir);
206-
207-
// Read the last package manager selection from the log
208-
const lastPM = log.lastPackageManager; // e.g. 'npm', or undefined if none recorded
209206
```
210207

211208
---

api/spfx-template-api/etc/spfx-template-api.api.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ export class PackageJsonMergeHelper extends JsonMergeHelper {
216216
merge(existingContent: string, newContent: string): string;
217217
}
218218

219+
// @public
220+
export type PackageManager = (typeof VALID_PACKAGE_MANAGERS)[number];
221+
219222
// @public
220223
export class PackageSolutionJsonMergeHelper extends JsonMergeHelper {
221224
// (undocumented)
@@ -256,7 +259,6 @@ export class SPFxScaffoldLog {
256259
kind: K;
257260
}>[];
258261
get hasEntries(): boolean;
259-
get lastPackageManager(): string | undefined;
260262
static loadFromFolderAsync(targetDir: string): Promise<SPFxScaffoldLog>;
261263
saveToFolderAsync(targetDir: string): Promise<void>;
262264
toJsonl(): string;
@@ -334,4 +336,13 @@ export class TemplateOutput {
334336
// @public
335337
export function toHyphenCase(input: string): string;
336338

339+
// @public
340+
export function tryReadPackageManagerFromPackageJsonEnginesAsync(targetDir: string): Promise<PackageManager | undefined>;
341+
342+
// @public (undocumented)
343+
export const VALID_PACKAGE_MANAGERS: readonly ["npm", "pnpm", "yarn"];
344+
345+
// @public
346+
export function writePackageManagerToPackageJsonEnginesAsync(packageManager: PackageManager, targetDir: string, terminal: ITerminal): Promise<void>;
347+
337348
```

api/spfx-template-api/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ export {
4646
SPFxTemplateWriter,
4747
type IWriteOptions,
4848
type ITemplateOutputEntry,
49-
TemplateOutput
49+
TemplateOutput,
50+
type VALID_PACKAGE_MANAGERS,
51+
type PackageManager,
52+
tryReadPackageManagerFromPackageJsonEnginesAsync,
53+
writePackageManagerToPackageJsonEnginesAsync
5054
} from './writing/index';
5155
export {
5256
type ISPFxScaffoldEventBase,

api/spfx-template-api/src/logging/SPFxScaffoldLog.ts

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import { FileSystem } from '@rushstack/node-core-library';
55

6-
import type { IPackageManagerSelectedEvent, ISPFxScaffoldEvent } from './SPFxScaffoldEvent';
6+
import type { ISPFxScaffoldEvent } from './SPFxScaffoldEvent';
77

88
/**
99
* The well-known filename for the persisted scaffold log.
@@ -36,7 +36,6 @@ export type ISPFxScaffoldEventInput = ISPFxScaffoldEvent extends infer E
3636
*/
3737
export class SPFxScaffoldLog {
3838
private readonly _events: ISPFxScaffoldEvent[] = [];
39-
private _lastPackageManager: string | undefined;
4039

4140
/**
4241
* Appends an event to the log. If `timestamp` is omitted or empty
@@ -48,13 +47,6 @@ export class SPFxScaffoldLog {
4847
timestamp: event.timestamp || new Date().toISOString()
4948
} as ISPFxScaffoldEvent;
5049
this._events.push(normalizedEvent);
51-
52-
if (normalizedEvent.kind === 'package-manager-selected') {
53-
const pm: string = (normalizedEvent as IPackageManagerSelectedEvent).packageManager;
54-
if (pm !== 'none') {
55-
this._lastPackageManager = pm;
56-
}
57-
}
5850
}
5951

6052
/** Whether the log contains any events. */
@@ -76,18 +68,6 @@ export class SPFxScaffoldLog {
7668
return this._events.filter((e): e is Extract<ISPFxScaffoldEvent, { kind: K }> => e.kind === kind);
7769
}
7870

79-
/**
80-
* Returns the package manager from the most recent `package-manager-selected`
81-
* event, or `undefined` if none has been recorded or the last selection was `'none'`.
82-
*
83-
* @remarks
84-
* This value is cached and updated incrementally on each {@link SPFxScaffoldLog.append}
85-
* call, so reading it is O(1).
86-
*/
87-
public get lastPackageManager(): string | undefined {
88-
return this._lastPackageManager;
89-
}
90-
9171
/** Serializes the log to JSONL (one JSON object per line, no trailing newline). */
9272
public toJsonl(): string {
9373
return this._events.map((e) => JSON.stringify(e)).join('\n');

api/spfx-template-api/src/logging/test/SPFxScaffoldLog.test.ts

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -287,47 +287,6 @@ describe(SPFxScaffoldLog.name, () => {
287287
});
288288
});
289289

290-
// ---- lastPackageManager ---------------------------------------------------
291-
292-
describe('lastPackageManager', () => {
293-
it('returns undefined when no package-manager-selected events exist', () => {
294-
const log: SPFxScaffoldLog = new SPFxScaffoldLog();
295-
log.append(makeFileWriteEvent());
296-
expect(log.lastPackageManager).toBeUndefined();
297-
});
298-
299-
it('returns undefined for an empty log', () => {
300-
const log: SPFxScaffoldLog = new SPFxScaffoldLog();
301-
expect(log.lastPackageManager).toBeUndefined();
302-
});
303-
304-
it('returns undefined when the only selection was "none"', () => {
305-
const log: SPFxScaffoldLog = new SPFxScaffoldLog();
306-
log.append(makePackageManagerSelectedEvent({ packageManager: 'none' }));
307-
expect(log.lastPackageManager).toBeUndefined();
308-
});
309-
310-
it('does not clear a previously recorded manager when "none" is appended', () => {
311-
const log: SPFxScaffoldLog = new SPFxScaffoldLog();
312-
log.append(makePackageManagerSelectedEvent({ packageManager: 'npm' }));
313-
log.append(makePackageManagerSelectedEvent({ packageManager: 'none' }));
314-
expect(log.lastPackageManager).toBe('npm');
315-
});
316-
317-
it('returns the package manager from the most recent non-none event', () => {
318-
const log: SPFxScaffoldLog = new SPFxScaffoldLog();
319-
log.append(makePackageManagerSelectedEvent({ packageManager: 'npm' }));
320-
log.append(makePackageManagerSelectedEvent({ packageManager: 'pnpm' }));
321-
expect(log.lastPackageManager).toBe('pnpm');
322-
});
323-
324-
it('returns the package manager when only one event exists', () => {
325-
const log: SPFxScaffoldLog = new SPFxScaffoldLog();
326-
log.append(makePackageManagerSelectedEvent({ packageManager: 'yarn' }));
327-
expect(log.lastPackageManager).toBe('yarn');
328-
});
329-
});
330-
331290
// ---- loadAsync ----------------------------------------------------------
332291

333292
describe(SPFxScaffoldLog.loadFromFolderAsync.name, () => {
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type { ChildProcess } from 'node:child_process';
5+
6+
import { Executable, FileSystem, JsonFile, type IPackageJson } from '@rushstack/node-core-library';
7+
import type { ITerminal } from '@rushstack/terminal';
8+
9+
/**
10+
* @public
11+
*/
12+
// eslint-disable-next-line @typescript-eslint/typedef
13+
export const VALID_PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn'] as const;
14+
15+
/**
16+
* The package managers supported by the SPFx CLI.
17+
*
18+
* @public
19+
*/
20+
export type PackageManager = (typeof VALID_PACKAGE_MANAGERS)[number];
21+
22+
const VALID_PACKAGE_MANAGERS_SET: ReadonlySet<PackageManager> = new Set<PackageManager>(
23+
VALID_PACKAGE_MANAGERS
24+
);
25+
26+
interface IPackageJsonWithEngines extends IPackageJson {
27+
engines?: Record<string, string>;
28+
}
29+
30+
/**
31+
* Reads the project's `package.json` and returns the package manager recorded in its `"engines"`
32+
* field, or `undefined` if none is present or the file does not exist.
33+
*
34+
* @throws If multiple known package managers are found in the `"engines"` field.
35+
*
36+
* @public
37+
*/
38+
export async function tryReadPackageManagerFromPackageJsonEnginesAsync(
39+
targetDir: string
40+
): Promise<PackageManager | undefined> {
41+
const filePath: string = `${targetDir}/package.json`;
42+
let engines: IPackageJsonWithEngines['engines'] | undefined;
43+
try {
44+
const packageJson: IPackageJsonWithEngines = await JsonFile.loadAsync(filePath);
45+
({ engines } = packageJson);
46+
} catch (error) {
47+
if (!FileSystem.isNotExistError(error)) {
48+
throw error;
49+
}
50+
}
51+
52+
if (engines) {
53+
const foundPackageManagers: PackageManager[] = [];
54+
for (const engine of Object.keys(engines)) {
55+
const maybePackageManager: PackageManager = engine as PackageManager;
56+
if (VALID_PACKAGE_MANAGERS_SET.has(maybePackageManager)) {
57+
foundPackageManagers.push(maybePackageManager);
58+
}
59+
}
60+
61+
if (foundPackageManagers.length > 1) {
62+
throw new Error(
63+
`Found multiple package managers in the "engines" field of package.json: ` +
64+
`${foundPackageManagers.join(', ')}. Only one package manager should be specified.`
65+
);
66+
}
67+
68+
return foundPackageManagers[0];
69+
}
70+
71+
return undefined;
72+
}
73+
74+
/**
75+
* Detects the installed version of the given package manager, then writes a `>=MAJOR` constraint
76+
* into the `"engines"` field of the project's `package.json`.
77+
*
78+
* Emits a warning and skips silently on version-detection or file-read failures so that a missing
79+
* or undetectable package manager never fails the overall scaffold.
80+
*
81+
* @public
82+
*/
83+
export async function writePackageManagerToPackageJsonEnginesAsync(
84+
packageManager: PackageManager,
85+
targetDir: string,
86+
terminal: ITerminal
87+
): Promise<void> {
88+
const versionChild: ChildProcess = Executable.spawn(packageManager, ['--version'], {
89+
currentWorkingDirectory: targetDir,
90+
stdio: 'pipe'
91+
});
92+
93+
const { stdout } = await Executable.waitForExitAsync(versionChild, {
94+
throwOnNonZeroExitCode: false,
95+
throwOnSignal: false,
96+
encoding: 'utf-8'
97+
});
98+
99+
const majorVersion: number = parseInt(stdout.trim(), 10);
100+
if (Number.isNaN(majorVersion)) {
101+
terminal.writeWarningLine(
102+
`Could not detect ${packageManager} version; skipping engines field update in package.json.`
103+
);
104+
} else {
105+
const filePath: string = `${targetDir}/package.json`;
106+
let packageJson: IPackageJsonWithEngines | undefined;
107+
try {
108+
packageJson = await JsonFile.loadAsync(filePath);
109+
} catch (error) {
110+
if (FileSystem.isNotExistError(error)) {
111+
terminal.writeWarningLine(
112+
`Could not find package.json in ${targetDir}; skipping engines field update.`
113+
);
114+
} else {
115+
throw error;
116+
}
117+
}
118+
119+
if (packageJson) {
120+
packageJson.engines ??= {};
121+
packageJson.engines[packageManager] = `>=${majorVersion}`;
122+
await JsonFile.saveAsync(packageJson, filePath, { updateExistingFile: true });
123+
}
124+
}
125+
}

api/spfx-template-api/src/writing/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@ export { PackageSolutionJsonMergeHelper } from './PackageSolutionJsonMergeHelper
99
export { ServeJsonMergeHelper } from './ServeJsonMergeHelper';
1010
export { SPFxTemplateWriter, type IWriteOptions } from './SPFxTemplateWriter';
1111
export { type ITemplateOutputEntry, TemplateOutput } from './TemplateOutput';
12+
export {
13+
type VALID_PACKAGE_MANAGERS,
14+
type PackageManager,
15+
tryReadPackageManagerFromPackageJsonEnginesAsync,
16+
writePackageManagerToPackageJsonEnginesAsync
17+
} from './PackageManagerEnginesHelper';

0 commit comments

Comments
 (0)