Skip to content

Commit 31fcb75

Browse files
committed
Merge pull request #30930 from storybookjs/shilman/cli-new-users
CLI: Add skip onboarding, recommended/minimal config (cherry picked from commit dde78a0)
1 parent 64be12f commit 31fcb75

File tree

18 files changed

+2058
-175
lines changed

18 files changed

+2058
-175
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"editor.formatOnSave": true
99
},
1010
"[typescript]": {
11-
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
11+
"editor.defaultFormatter": "esbenp.prettier-vscode",
1212
"editor.formatOnSave": true
1313
},
1414
"[typescriptreact]": {

code/core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,8 @@
417417
"typescript": "^5.7.3",
418418
"unique-string": "^3.0.0",
419419
"use-resize-observer": "^9.1.0",
420-
"watchpack": "^2.2.0"
420+
"watchpack": "^2.2.0",
421+
"zod": "^3.24.1"
421422
},
422423
"peerDependencies": {
423424
"prettier": "^2 || ^3"

code/core/src/cli/bin/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import invariant from 'tiny-invariant';
1212
import { version } from '../../../package.json';
1313
import { build } from '../build';
1414
import { dev } from '../dev';
15+
import { globalSettings } from '../globalSettings';
1516

1617
addToGlobalContext('cliVersion', versions.storybook);
1718

@@ -27,7 +28,14 @@ const command = (name: string) =>
2728
process.env.STORYBOOK_DISABLE_TELEMETRY && process.env.STORYBOOK_DISABLE_TELEMETRY !== 'false'
2829
)
2930
.option('--debug', 'Get more logs in debug mode', false)
30-
.option('--enable-crash-reports', 'Enable sending crash reports to telemetry data');
31+
.option('--enable-crash-reports', 'Enable sending crash reports to telemetry data')
32+
.hook('preAction', async () => {
33+
try {
34+
await globalSettings();
35+
} catch (e) {
36+
consoleLogger.error('Error loading global settings', e);
37+
}
38+
});
3139

3240
command('dev')
3341
.option('-p, --port <number>', 'Port to run Storybook', (str) => parseInt(str, 10))
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import fs from 'node:fs/promises';
2+
import { dirname } from 'node:path';
3+
import { afterEach } from 'node:test';
4+
5+
import { beforeEach, describe, expect, it, vi } from 'vitest';
6+
7+
import { type Settings, _clearGlobalSettings, globalSettings } from './globalSettings';
8+
9+
vi.mock('node:fs');
10+
vi.mock('node:fs/promises');
11+
12+
const userSince = new Date();
13+
const baseSettings = { version: 1, userSince: +userSince };
14+
const baseSettingsJson = JSON.stringify(baseSettings, null, 2);
15+
16+
const TEST_SETTINGS_FILE = '/test/settings.json';
17+
18+
beforeEach(() => {
19+
_clearGlobalSettings();
20+
21+
vi.useFakeTimers();
22+
vi.setSystemTime(userSince);
23+
24+
vi.resetAllMocks();
25+
});
26+
27+
afterEach(() => {
28+
vi.useRealTimers();
29+
});
30+
31+
describe('globalSettings', () => {
32+
it('loads settings when called for the first time', async () => {
33+
vi.mocked(fs.readFile).mockResolvedValue(baseSettingsJson);
34+
35+
const settings = await globalSettings(TEST_SETTINGS_FILE);
36+
37+
expect(settings.value.userSince).toBe(+userSince);
38+
});
39+
40+
it('does nothing if settings are already loaded', async () => {
41+
vi.mocked(fs.readFile).mockResolvedValue(baseSettingsJson);
42+
await globalSettings(TEST_SETTINGS_FILE);
43+
44+
vi.mocked(fs.readFile).mockClear();
45+
await globalSettings(TEST_SETTINGS_FILE);
46+
expect(fs.readFile).not.toHaveBeenCalled();
47+
});
48+
49+
it('does not save settings if they exist', async () => {
50+
vi.mocked(fs.readFile).mockResolvedValue(baseSettingsJson);
51+
52+
await globalSettings(TEST_SETTINGS_FILE);
53+
54+
expect(fs.writeFile).not.toHaveBeenCalled();
55+
});
56+
57+
it('saves settings and creates directory if they do not exist', async () => {
58+
const error = new Error() as Error & { code: string };
59+
error.code = 'ENOENT';
60+
vi.mocked(fs.readFile).mockRejectedValue(error);
61+
62+
await globalSettings(TEST_SETTINGS_FILE);
63+
64+
expect(fs.mkdir).toHaveBeenCalledWith(dirname(TEST_SETTINGS_FILE), { recursive: true });
65+
expect(fs.writeFile).toHaveBeenCalledWith(TEST_SETTINGS_FILE, baseSettingsJson);
66+
});
67+
});
68+
69+
describe('Settings', () => {
70+
let settings: Settings;
71+
beforeEach(async () => {
72+
vi.mocked(fs.readFile).mockResolvedValue(baseSettingsJson);
73+
74+
settings = await globalSettings(TEST_SETTINGS_FILE);
75+
});
76+
77+
describe('save', () => {
78+
it('overwrites existing settings', async () => {
79+
settings.value.init = { skipOnboarding: true };
80+
await settings.save();
81+
82+
expect(fs.writeFile).toHaveBeenCalledWith(
83+
TEST_SETTINGS_FILE,
84+
JSON.stringify({ ...baseSettings, init: { skipOnboarding: true } }, null, 2)
85+
);
86+
});
87+
88+
it('throws error if write fails', async () => {
89+
vi.mocked(fs.writeFile).mockRejectedValue(new Error('Write error'));
90+
91+
await expect(settings.save()).rejects.toThrow('Unable to save global settings');
92+
});
93+
94+
it('throws error if directory creation fails', async () => {
95+
vi.mocked(fs.mkdir).mockRejectedValue(new Error('Directory creation error'));
96+
97+
await expect(settings.save()).rejects.toThrow('Unable to save global settings');
98+
});
99+
});
100+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import fs from 'node:fs/promises';
2+
import { homedir } from 'node:os';
3+
import { dirname, join } from 'node:path';
4+
5+
import { z } from 'zod';
6+
7+
import { SavingGlobalSettingsFileError } from '../server-errors';
8+
9+
const DEFAULT_SETTINGS_PATH = join(homedir(), '.storybook', 'settings.json');
10+
11+
const VERSION = 1;
12+
13+
const userSettingSchema = z.object({
14+
version: z.number(),
15+
// NOTE: every key (and subkey) below must be optional, for forwards compatibility reasons
16+
// (we can remove keys once they are deprecated)
17+
userSince: z.number().optional(),
18+
init: z.object({ skipOnboarding: z.boolean().optional() }).optional(),
19+
});
20+
21+
let settings: Settings | undefined;
22+
export async function globalSettings(filePath = DEFAULT_SETTINGS_PATH) {
23+
if (settings) {
24+
return settings;
25+
}
26+
27+
try {
28+
const content = await fs.readFile(filePath, 'utf8');
29+
const settingsValue = userSettingSchema.parse(JSON.parse(content));
30+
settings = new Settings(filePath, settingsValue);
31+
} catch (err: any) {
32+
// We don't currently log the issue we have loading the setting file here, but if it doesn't
33+
// yet exist we'll get err.code = 'ENOENT'
34+
35+
// There is no existing settings file or it has a problem;
36+
settings = new Settings(filePath, { version: VERSION, userSince: Date.now() });
37+
await settings.save();
38+
}
39+
40+
return settings;
41+
}
42+
43+
// For testing
44+
export function _clearGlobalSettings() {
45+
settings = undefined;
46+
}
47+
48+
/**
49+
* A class for reading and writing settings from a JSON file. Supports nested settings with dot
50+
* notation.
51+
*/
52+
export class Settings {
53+
private filePath: string;
54+
55+
public value: z.infer<typeof userSettingSchema>;
56+
57+
/**
58+
* Create a new Settings instance
59+
*
60+
* @param filePath Path to the JSON settings file
61+
* @param value Loaded value of settings
62+
*/
63+
constructor(filePath: string, value: z.infer<typeof userSettingSchema>) {
64+
this.filePath = filePath;
65+
this.value = value;
66+
}
67+
68+
/** Save settings to the file */
69+
async save(): Promise<void> {
70+
try {
71+
await fs.mkdir(dirname(this.filePath), { recursive: true });
72+
await fs.writeFile(this.filePath, JSON.stringify(this.value, null, 2));
73+
} catch (err) {
74+
throw new SavingGlobalSettingsFileError({
75+
filePath: this.filePath,
76+
error: err,
77+
});
78+
}
79+
}
80+
}

code/core/src/cli/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from './dirs';
55
export * from './project_types';
66
export * from './NpmOptions';
77
export * from './eslintPlugin';
8+
export * from './globalSettings';

code/core/src/common/utils/notify-telemetry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const notifyTelemetry = async () => {
2525
);
2626
logger.log(`This information is used to shape Storybook's roadmap and prioritize features.`);
2727
logger.log(
28-
`You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:`
28+
`You can learn more, including how to opt-out of this anonymous program, by visiting:`
2929
);
3030
logger.log(picocolors.cyan('https://storybook.js.org/telemetry'));
3131
logger.log();

code/core/src/manager/globals/exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export default {
268268
'SubtractIcon',
269269
'SunIcon',
270270
'SupportIcon',
271+
'SweepIcon',
271272
'SwitchAltIcon',
272273
'SyncIcon',
273274
'TabletIcon',

code/core/src/server-errors.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,3 +538,14 @@ export class FindPackageVersionsError extends StorybookError {
538538
});
539539
}
540540
}
541+
export class SavingGlobalSettingsFileError extends StorybookError {
542+
constructor(public data: { filePath: string; error: Error | unknown }) {
543+
super({
544+
category: Category.CORE_SERVER,
545+
code: 1,
546+
message: dedent`
547+
Unable to save global settings file to ${data.filePath}
548+
${data.error && `Reason: ${data.error}`}`,
549+
});
550+
}
551+
}

code/core/src/telemetry/storybook-metadata.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,5 +420,15 @@ describe('storybook-metadata', () => {
420420
});
421421
}
422422
);
423+
424+
it('should detect userSince info', async () => {
425+
const res = await computeStorybookMetadata({
426+
packageJson: packageJsonMock,
427+
packageJsonPath,
428+
mainConfig: mainJsMock,
429+
});
430+
431+
expect(res.userSince).toBeDefined();
432+
});
423433
});
424434
});

0 commit comments

Comments
 (0)