@epic-web/workshop-utils 6.71.4 → 6.72.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.
@@ -197,20 +197,38 @@ export declare const checkForUpdatesCache: {
197
197
  name: string;
198
198
  set: (key: string, value: C.CacheEntry<{
199
199
  updatesAvailable: boolean;
200
- localCommit: string;
201
- remoteCommit: string;
200
+ repoUpdatesAvailable: boolean;
201
+ dependenciesNeedInstall: boolean;
202
+ updateNotificationId: string | null;
203
+ commitsAhead: number | null;
204
+ commitsBehind: number | null;
205
+ localCommit: string | null;
206
+ remoteCommit: string | null;
202
207
  diffLink: string | null;
208
+ message: string | null;
203
209
  }>) => C.CacheEntry<{
204
210
  updatesAvailable: boolean;
205
- localCommit: string;
206
- remoteCommit: string;
211
+ repoUpdatesAvailable: boolean;
212
+ dependenciesNeedInstall: boolean;
213
+ updateNotificationId: string | null;
214
+ commitsAhead: number | null;
215
+ commitsBehind: number | null;
216
+ localCommit: string | null;
217
+ remoteCommit: string | null;
207
218
  diffLink: string | null;
219
+ message: string | null;
208
220
  }>;
209
221
  get: (key: string) => C.CacheEntry<{
210
222
  updatesAvailable: boolean;
211
- localCommit: string;
212
- remoteCommit: string;
223
+ repoUpdatesAvailable: boolean;
224
+ dependenciesNeedInstall: boolean;
225
+ updateNotificationId: string | null;
226
+ commitsAhead: number | null;
227
+ commitsBehind: number | null;
228
+ localCommit: string | null;
229
+ remoteCommit: string | null;
213
230
  diffLink: string | null;
231
+ message: string | null;
214
232
  }> | undefined;
215
233
  delete: (key: string) => boolean;
216
234
  };
@@ -1,38 +1,53 @@
1
1
  import "./init-env.js";
2
2
  export declare function checkForUpdates(): Promise<{
3
3
  readonly updatesAvailable: false;
4
+ readonly dependenciesNeedInstall: false;
5
+ readonly updateNotificationId: null;
4
6
  readonly message: "The app is deployed";
5
- readonly commitsAhead?: undefined;
6
- readonly commitsBehind?: undefined;
7
- readonly localCommit?: undefined;
8
- readonly remoteCommit?: undefined;
9
- readonly diffLink?: undefined;
7
+ readonly repoUpdatesAvailable: boolean;
8
+ readonly commitsAhead: null;
9
+ readonly commitsBehind: null;
10
+ readonly localCommit: null;
11
+ readonly remoteCommit: null;
12
+ readonly diffLink: null;
10
13
  } | {
11
- readonly updatesAvailable: false;
12
14
  readonly message: "You are offline";
13
- readonly commitsAhead?: undefined;
14
- readonly commitsBehind?: undefined;
15
- readonly localCommit?: undefined;
16
- readonly remoteCommit?: undefined;
17
- readonly diffLink?: undefined;
15
+ readonly updatesAvailable: boolean;
16
+ readonly repoUpdatesAvailable: boolean;
17
+ readonly dependenciesNeedInstall: boolean;
18
+ readonly updateNotificationId: string | null;
19
+ readonly commitsAhead: null;
20
+ readonly commitsBehind: null;
21
+ readonly localCommit: null;
22
+ readonly remoteCommit: null;
23
+ readonly diffLink: null;
18
24
  } | {
19
- readonly updatesAvailable: false;
20
25
  readonly message: "Not in a git repo";
21
- readonly commitsAhead?: undefined;
22
- readonly commitsBehind?: undefined;
23
- readonly localCommit?: undefined;
24
- readonly remoteCommit?: undefined;
25
- readonly diffLink?: undefined;
26
+ readonly updatesAvailable: boolean;
27
+ readonly repoUpdatesAvailable: boolean;
28
+ readonly dependenciesNeedInstall: boolean;
29
+ readonly updateNotificationId: string | null;
30
+ readonly commitsAhead: null;
31
+ readonly commitsBehind: null;
32
+ readonly localCommit: null;
33
+ readonly remoteCommit: null;
34
+ readonly diffLink: null;
26
35
  } | {
27
- readonly updatesAvailable: false;
28
36
  readonly message: "Cannot find remote";
29
- readonly commitsAhead?: undefined;
30
- readonly commitsBehind?: undefined;
31
- readonly localCommit?: undefined;
32
- readonly remoteCommit?: undefined;
33
- readonly diffLink?: undefined;
37
+ readonly updatesAvailable: boolean;
38
+ readonly repoUpdatesAvailable: boolean;
39
+ readonly dependenciesNeedInstall: boolean;
40
+ readonly updateNotificationId: string | null;
41
+ readonly commitsAhead: null;
42
+ readonly commitsBehind: null;
43
+ readonly localCommit: null;
44
+ readonly remoteCommit: null;
45
+ readonly diffLink: null;
34
46
  } | {
35
47
  readonly updatesAvailable: boolean;
48
+ readonly repoUpdatesAvailable: boolean;
49
+ readonly dependenciesNeedInstall: boolean;
50
+ readonly updateNotificationId: string | null;
36
51
  readonly commitsAhead: number;
37
52
  readonly commitsBehind: number;
38
53
  readonly localCommit: string;
@@ -40,48 +55,66 @@ export declare function checkForUpdates(): Promise<{
40
55
  readonly diffLink: string | null;
41
56
  readonly message: null;
42
57
  } | {
43
- readonly updatesAvailable: false;
44
- readonly localCommit: string | undefined;
45
- readonly remoteCommit: string | undefined;
58
+ readonly localCommit: string | null;
59
+ readonly remoteCommit: string | null;
46
60
  readonly diffLink: string | null;
47
- readonly message?: undefined;
48
- readonly commitsAhead?: undefined;
49
- readonly commitsBehind?: undefined;
61
+ readonly updatesAvailable: boolean;
62
+ readonly repoUpdatesAvailable: boolean;
63
+ readonly dependenciesNeedInstall: boolean;
64
+ readonly updateNotificationId: string | null;
65
+ readonly commitsAhead: null;
66
+ readonly commitsBehind: null;
67
+ readonly message: null;
50
68
  }>;
51
69
  export declare function checkForUpdatesCached(): Promise<{
52
70
  readonly updatesAvailable: false;
71
+ readonly dependenciesNeedInstall: false;
72
+ readonly updateNotificationId: null;
53
73
  readonly message: "The app is deployed";
54
- readonly commitsAhead?: undefined;
55
- readonly commitsBehind?: undefined;
56
- readonly localCommit?: undefined;
57
- readonly remoteCommit?: undefined;
58
- readonly diffLink?: undefined;
74
+ readonly repoUpdatesAvailable: boolean;
75
+ readonly commitsAhead: null;
76
+ readonly commitsBehind: null;
77
+ readonly localCommit: null;
78
+ readonly remoteCommit: null;
79
+ readonly diffLink: null;
59
80
  } | {
60
- readonly updatesAvailable: false;
61
81
  readonly message: "You are offline";
62
- readonly commitsAhead?: undefined;
63
- readonly commitsBehind?: undefined;
64
- readonly localCommit?: undefined;
65
- readonly remoteCommit?: undefined;
66
- readonly diffLink?: undefined;
82
+ readonly updatesAvailable: boolean;
83
+ readonly repoUpdatesAvailable: boolean;
84
+ readonly dependenciesNeedInstall: boolean;
85
+ readonly updateNotificationId: string | null;
86
+ readonly commitsAhead: null;
87
+ readonly commitsBehind: null;
88
+ readonly localCommit: null;
89
+ readonly remoteCommit: null;
90
+ readonly diffLink: null;
67
91
  } | {
68
- readonly updatesAvailable: false;
69
92
  readonly message: "Not in a git repo";
70
- readonly commitsAhead?: undefined;
71
- readonly commitsBehind?: undefined;
72
- readonly localCommit?: undefined;
73
- readonly remoteCommit?: undefined;
74
- readonly diffLink?: undefined;
93
+ readonly updatesAvailable: boolean;
94
+ readonly repoUpdatesAvailable: boolean;
95
+ readonly dependenciesNeedInstall: boolean;
96
+ readonly updateNotificationId: string | null;
97
+ readonly commitsAhead: null;
98
+ readonly commitsBehind: null;
99
+ readonly localCommit: null;
100
+ readonly remoteCommit: null;
101
+ readonly diffLink: null;
75
102
  } | {
76
- readonly updatesAvailable: false;
77
103
  readonly message: "Cannot find remote";
78
- readonly commitsAhead?: undefined;
79
- readonly commitsBehind?: undefined;
80
- readonly localCommit?: undefined;
81
- readonly remoteCommit?: undefined;
82
- readonly diffLink?: undefined;
104
+ readonly updatesAvailable: boolean;
105
+ readonly repoUpdatesAvailable: boolean;
106
+ readonly dependenciesNeedInstall: boolean;
107
+ readonly updateNotificationId: string | null;
108
+ readonly commitsAhead: null;
109
+ readonly commitsBehind: null;
110
+ readonly localCommit: null;
111
+ readonly remoteCommit: null;
112
+ readonly diffLink: null;
83
113
  } | {
84
114
  readonly updatesAvailable: boolean;
115
+ readonly repoUpdatesAvailable: boolean;
116
+ readonly dependenciesNeedInstall: boolean;
117
+ readonly updateNotificationId: string | null;
85
118
  readonly commitsAhead: number;
86
119
  readonly commitsBehind: number;
87
120
  readonly localCommit: string;
@@ -89,15 +122,16 @@ export declare function checkForUpdatesCached(): Promise<{
89
122
  readonly diffLink: string | null;
90
123
  readonly message: null;
91
124
  } | {
92
- readonly updatesAvailable: false;
93
- readonly localCommit: string | undefined;
94
- readonly remoteCommit: string | undefined;
125
+ readonly localCommit: string | null;
126
+ readonly remoteCommit: string | null;
95
127
  readonly diffLink: string | null;
96
- readonly message?: undefined;
97
- readonly commitsAhead?: undefined;
98
- readonly commitsBehind?: undefined;
99
- } | {
100
- readonly updatesAvailable: false;
128
+ readonly updatesAvailable: boolean;
129
+ readonly repoUpdatesAvailable: boolean;
130
+ readonly dependenciesNeedInstall: boolean;
131
+ readonly updateNotificationId: string | null;
132
+ readonly commitsAhead: null;
133
+ readonly commitsBehind: null;
134
+ readonly message: null;
101
135
  }>;
102
136
  export declare function updateLocalRepo(): Promise<{
103
137
  readonly status: "success";
@@ -107,7 +141,7 @@ export declare function updateLocalRepo(): Promise<{
107
141
  readonly message: string;
108
142
  } | {
109
143
  readonly status: "success";
110
- readonly message: "Updated successfully.";
144
+ readonly message: "Updated successfully." | "Dependencies updated successfully.";
111
145
  }>;
112
146
  export declare function getCommitInfo(): Promise<{
113
147
  hash: string;
@@ -7,6 +7,7 @@ import { cachified, checkForUpdatesCache } from "./cache.server.js";
7
7
  import { getWorkshopConfig } from "./config.server.js";
8
8
  import { getEnv } from "./env.server.js";
9
9
  import { logger } from "./logger.js";
10
+ import { getInstallCommand, getWorkspaceInstallStatus, } from "./package-install/package-install-check.server.js";
10
11
  import { checkConnection } from "./utils.server.js";
11
12
  import { getErrorMessage } from "./utils.js";
12
13
  const gitLog = logger('epic:git');
@@ -16,6 +17,11 @@ function dirHasTrackedFiles(cwd, dirPath) {
16
17
  function isDirectory(dirPath) {
17
18
  return fs.stat(dirPath).then((s) => s.isDirectory(), () => false);
18
19
  }
20
+ function getDependencyNotificationId(dependencyHash) {
21
+ if (!dependencyHash)
22
+ return null;
23
+ return `update-deps-${dependencyHash}`;
24
+ }
19
25
  async function cleanupEmptyExerciseDirectories(cwd) {
20
26
  console.log('🧹 Cleaning up empty exercise directories...');
21
27
  try {
@@ -62,25 +68,48 @@ async function getDiffUrl(commitBefore, commitAfter) {
62
68
  }
63
69
  export async function checkForUpdates() {
64
70
  const ENV = getEnv();
71
+ const cwd = getWorkshopRoot();
72
+ const dependencyStatus = await getWorkspaceInstallStatus(cwd);
73
+ const dependencyNotificationId = dependencyStatus.dependenciesNeedInstall
74
+ ? getDependencyNotificationId(dependencyStatus.dependencyHash)
75
+ : null;
76
+ const baseResult = {
77
+ updatesAvailable: dependencyStatus.dependenciesNeedInstall,
78
+ repoUpdatesAvailable: false,
79
+ dependenciesNeedInstall: dependencyStatus.dependenciesNeedInstall,
80
+ updateNotificationId: dependencyNotificationId,
81
+ commitsAhead: null,
82
+ commitsBehind: null,
83
+ localCommit: null,
84
+ remoteCommit: null,
85
+ diffLink: null,
86
+ message: null,
87
+ };
65
88
  if (ENV.EPICSHOP_DEPLOYED) {
66
- return { updatesAvailable: false, message: 'The app is deployed' };
89
+ return {
90
+ ...baseResult,
91
+ updatesAvailable: false,
92
+ dependenciesNeedInstall: false,
93
+ updateNotificationId: null,
94
+ message: 'The app is deployed',
95
+ };
67
96
  }
68
- const cwd = getWorkshopRoot();
69
97
  const online = await checkConnection();
70
98
  if (!online) {
71
- return { updatesAvailable: false, message: 'You are offline' };
99
+ return { ...baseResult, message: 'You are offline' };
72
100
  }
73
101
  const isInRepo = await execaCommand('git rev-parse --is-inside-work-tree', {
74
102
  cwd,
75
103
  }).then(() => true, () => false);
76
104
  if (!isInRepo) {
77
- return { updatesAvailable: false, message: 'Not in a git repo' };
105
+ return { ...baseResult, message: 'Not in a git repo' };
78
106
  }
79
107
  const { stdout: remote } = await execaCommand('git remote', { cwd });
80
108
  if (!remote) {
81
- return { updatesAvailable: false, message: 'Cannot find remote' };
109
+ return { ...baseResult, message: 'Cannot find remote' };
82
110
  }
83
- let localCommit, remoteCommit;
111
+ let localCommit = null;
112
+ let remoteCommit = null;
84
113
  try {
85
114
  const currentBranch = (await execaCommand('git rev-parse --abbrev-ref HEAD', { cwd })).stdout.trim();
86
115
  localCommit = (await execaCommand('git rev-parse --short HEAD', { cwd })).stdout.trim();
@@ -90,21 +119,28 @@ export async function checkForUpdates() {
90
119
  })).stdout.trim();
91
120
  const { stdout } = await execa('git', ['rev-list', '--count', '--left-right', 'HEAD...@{upstream}'], { cwd });
92
121
  const [ahead = 0, behind = 0] = stdout.trim().split(/\s+/).map(Number);
93
- const updatesAvailable = behind > 0;
122
+ const repoUpdatesAvailable = behind > 0;
123
+ const updatesAvailable = repoUpdatesAvailable || dependencyStatus.dependenciesNeedInstall;
124
+ const updateNotificationId = repoUpdatesAvailable
125
+ ? `update-repo-${remoteCommit}`
126
+ : dependencyNotificationId;
94
127
  return {
95
128
  updatesAvailable,
129
+ repoUpdatesAvailable,
130
+ dependenciesNeedInstall: dependencyStatus.dependenciesNeedInstall,
131
+ updateNotificationId,
96
132
  commitsAhead: ahead,
97
133
  commitsBehind: behind,
98
134
  localCommit,
99
135
  remoteCommit,
100
136
  diffLink: await getDiffUrl(localCommit, remoteCommit),
101
- message: null,
137
+ message: baseResult.message,
102
138
  };
103
139
  }
104
140
  catch (error) {
105
141
  console.error('Unable to check for updates', getErrorMessage(error));
106
142
  return {
107
- updatesAvailable: false,
143
+ ...baseResult,
108
144
  localCommit,
109
145
  remoteCommit,
110
146
  diffLink: localCommit && remoteCommit
@@ -116,7 +152,18 @@ export async function checkForUpdates() {
116
152
  export async function checkForUpdatesCached() {
117
153
  const ENV = getEnv();
118
154
  if (ENV.EPICSHOP_DEPLOYED) {
119
- return { updatesAvailable: false };
155
+ return {
156
+ updatesAvailable: false,
157
+ repoUpdatesAvailable: false,
158
+ dependenciesNeedInstall: false,
159
+ updateNotificationId: null,
160
+ commitsAhead: null,
161
+ commitsBehind: null,
162
+ localCommit: null,
163
+ remoteCommit: null,
164
+ diffLink: null,
165
+ message: 'The app is deployed',
166
+ };
120
167
  }
121
168
  const key = 'checkForUpdates';
122
169
  return cachified({
@@ -127,10 +174,10 @@ export async function checkForUpdatesCached() {
127
174
  cache: checkForUpdatesCache,
128
175
  });
129
176
  }
130
- async function runNpmInstallWithRetry(cwd, maxRetries = 3, baseDelayMs = 1000) {
177
+ async function runInstallWithRetry(cwd, command, args, maxRetries = 3, baseDelayMs = 1000) {
131
178
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
132
179
  try {
133
- await execaCommand('npm install', { cwd, stdio: 'inherit' });
180
+ await execa(command, args, { cwd, stdio: 'inherit' });
134
181
  return;
135
182
  }
136
183
  catch (error) {
@@ -160,49 +207,77 @@ export async function updateLocalRepo() {
160
207
  const cwd = getWorkshopRoot();
161
208
  try {
162
209
  const updates = await checkForUpdates();
163
- if (!updates.updatesAvailable) {
210
+ const repoUpdatesAvailable = updates.repoUpdatesAvailable;
211
+ let dependencyStatus = await getWorkspaceInstallStatus(cwd);
212
+ let rootsNeedingInstall = dependencyStatus.roots.filter((status) => status.dependenciesNeedInstall);
213
+ if (!repoUpdatesAvailable && rootsNeedingInstall.length === 0) {
164
214
  return {
165
215
  status: 'success',
166
216
  message: updates.message ?? 'No updates available.',
167
217
  };
168
218
  }
169
- const uncommittedChanges = (await execaCommand('git status --porcelain', { cwd })).stdout.trim()
170
- .length > 0;
171
- if (uncommittedChanges) {
172
- console.log('👜 Stashing uncommitted changes...');
173
- await execaCommand('git stash --include-untracked', { cwd });
219
+ let didPull = false;
220
+ let didInstall = false;
221
+ if (repoUpdatesAvailable) {
222
+ const uncommittedChanges = (await execaCommand('git status --porcelain', { cwd })).stdout.trim()
223
+ .length > 0;
224
+ if (uncommittedChanges) {
225
+ console.log('👜 Stashing uncommitted changes...');
226
+ await execaCommand('git stash --include-untracked', { cwd });
227
+ }
228
+ console.log('⬇️ Pulling latest changes...');
229
+ await execaCommand('git pull origin HEAD', { cwd });
230
+ if (uncommittedChanges) {
231
+ console.log('👜 re-applying stashed changes...');
232
+ await execaCommand('git stash pop', { cwd });
233
+ }
234
+ didPull = true;
235
+ dependencyStatus = await getWorkspaceInstallStatus(cwd);
236
+ rootsNeedingInstall = dependencyStatus.roots.filter((status) => status.dependenciesNeedInstall);
174
237
  }
175
- console.log('⬇️ Pulling latest changes...');
176
- await execaCommand('git pull origin HEAD', { cwd });
177
- if (uncommittedChanges) {
178
- console.log('👜 re-applying stashed changes...');
179
- await execaCommand('git stash pop', { cwd });
238
+ if (rootsNeedingInstall.length > 0) {
239
+ for (const root of rootsNeedingInstall) {
240
+ const rootLabel = path.relative(cwd, root.rootDir).replace(/\\/g, '/') || '.';
241
+ const { command, args } = getInstallCommand(root.packageManager);
242
+ const commandLabel = `${command} ${args.join(' ')}`.trim();
243
+ console.log(`📦 Installing dependencies in ${rootLabel} using ${commandLabel}...`);
244
+ try {
245
+ await runInstallWithRetry(root.rootDir, command, args);
246
+ didInstall = true;
247
+ }
248
+ catch (error) {
249
+ const isEbusy = error instanceof Error &&
250
+ (error.message.includes('EBUSY') ||
251
+ error.code === 'EBUSY');
252
+ if (isEbusy) {
253
+ return {
254
+ status: 'error',
255
+ message: `${commandLabel} failed: files are locked. ` +
256
+ 'Please close any editors or terminals using this directory, ' +
257
+ `then run: ${commandLabel}`,
258
+ };
259
+ }
260
+ throw error;
261
+ }
262
+ }
180
263
  }
181
- console.log('📦 Re-installing dependencies...');
182
- try {
183
- await runNpmInstallWithRetry(cwd);
264
+ else if (repoUpdatesAvailable) {
265
+ console.log('📦 Dependencies already match package.json. Skipping install.');
184
266
  }
185
- catch (error) {
186
- const isEbusy = error instanceof Error &&
187
- (error.message.includes('EBUSY') ||
188
- error.code === 'EBUSY');
189
- if (isEbusy) {
190
- return {
191
- status: 'error',
192
- message: 'npm install failed: files are locked. ' +
193
- 'Please close any editors or terminals using this directory, ' +
194
- 'then run: npm install',
195
- };
267
+ if (didPull || didInstall) {
268
+ await cleanupEmptyExerciseDirectories(cwd);
269
+ const postUpdateScript = getWorkshopConfig().scripts?.postupdate;
270
+ if (postUpdateScript) {
271
+ console.log('🏃 Running post update script...');
272
+ await execaCommand(postUpdateScript, { cwd, stdio: 'inherit' });
196
273
  }
197
- throw error;
198
274
  }
199
- await cleanupEmptyExerciseDirectories(cwd);
200
- const postUpdateScript = getWorkshopConfig().scripts?.postupdate;
201
- if (postUpdateScript) {
202
- console.log('🏃 Running post update script...');
203
- await execaCommand(postUpdateScript, { cwd, stdio: 'inherit' });
204
- }
205
- return { status: 'success', message: 'Updated successfully.' };
275
+ return {
276
+ status: 'success',
277
+ message: repoUpdatesAvailable
278
+ ? 'Updated successfully.'
279
+ : 'Dependencies updated successfully.',
280
+ };
206
281
  }
207
282
  catch (error) {
208
283
  return { status: 'error', message: getErrorMessage(error) };
@@ -0,0 +1,23 @@
1
+ export type RootPackageInstallStatus = {
2
+ rootDir: string;
3
+ packageJsonPath: string;
4
+ packageManager: string | null;
5
+ dependencyHash: string | null;
6
+ dependenciesNeedInstall: boolean;
7
+ missingDependencies: Array<string>;
8
+ missingDevDependencies: Array<string>;
9
+ missingOptionalDependencies: Array<string>;
10
+ reason: 'missing-node-modules' | 'missing-dependencies' | 'package-json-unreadable' | 'up-to-date';
11
+ };
12
+ export type WorkspaceInstallStatus = {
13
+ roots: Array<RootPackageInstallStatus>;
14
+ dependenciesNeedInstall: boolean;
15
+ dependencyHash: string | null;
16
+ };
17
+ export declare function getRootPackageJsonPaths(cwd: string): Promise<string[]>;
18
+ export declare function getRootPackageInstallStatus(packageJsonPath: string): Promise<RootPackageInstallStatus>;
19
+ export declare function getWorkspaceInstallStatus(cwd: string): Promise<WorkspaceInstallStatus>;
20
+ export declare function getInstallCommand(packageManager: string | null): {
21
+ command: string;
22
+ args: string[];
23
+ };
@@ -0,0 +1,229 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { globby } from 'globby';
5
+ import { getErrorMessage } from "../utils.js";
6
+ const workspaceIgnorePatterns = [
7
+ '**/node_modules/**',
8
+ '**/.git/**',
9
+ '**/.cache/**',
10
+ '**/dist/**',
11
+ '**/build/**',
12
+ '**/coverage/**',
13
+ ];
14
+ function hashString(value) {
15
+ return createHash('sha256').update(value).digest('hex').slice(0, 8);
16
+ }
17
+ function normalizeDependencyMap(dependencies) {
18
+ const entries = Object.entries(dependencies ?? {}).sort(([a], [b]) => a.localeCompare(b));
19
+ return Object.fromEntries(entries);
20
+ }
21
+ function getDependencySnapshot(packageJson) {
22
+ return {
23
+ dependencies: normalizeDependencyMap(packageJson.dependencies),
24
+ devDependencies: normalizeDependencyMap(packageJson.devDependencies),
25
+ optionalDependencies: normalizeDependencyMap(packageJson.optionalDependencies),
26
+ };
27
+ }
28
+ function parsePackageManager(value) {
29
+ if (!value)
30
+ return null;
31
+ const [name] = value.split('@');
32
+ return name || null;
33
+ }
34
+ function normalizeWorkspacePattern(pattern) {
35
+ const trimmed = pattern.trim();
36
+ if (!trimmed)
37
+ return trimmed;
38
+ const isNegated = trimmed.startsWith('!');
39
+ const raw = isNegated ? trimmed.slice(1) : trimmed;
40
+ const normalized = raw.replace(/\\/g, '/');
41
+ const withPackageJson = normalized.endsWith('package.json')
42
+ ? normalized
43
+ : path.posix.join(normalized, 'package.json');
44
+ return isNegated ? `!${withPackageJson}` : withPackageJson;
45
+ }
46
+ async function readPackageJson(filePath) {
47
+ try {
48
+ const contents = await fs.readFile(filePath, 'utf8');
49
+ return JSON.parse(contents);
50
+ }
51
+ catch (error) {
52
+ console.warn(`⚠️ Failed to read package.json at ${filePath}:`, getErrorMessage(error));
53
+ return null;
54
+ }
55
+ }
56
+ async function getWorkspacePackageJsonPaths(packageJsonPath) {
57
+ const packageJson = await readPackageJson(packageJsonPath);
58
+ if (!packageJson)
59
+ return [];
60
+ const workspaces = Array.isArray(packageJson.workspaces)
61
+ ? packageJson.workspaces
62
+ : (packageJson.workspaces?.packages ?? []);
63
+ if (!workspaces.length)
64
+ return [];
65
+ const workspacePatterns = workspaces.map(normalizeWorkspacePattern);
66
+ return globby(workspacePatterns, {
67
+ cwd: path.dirname(packageJsonPath),
68
+ absolute: true,
69
+ ignore: workspaceIgnorePatterns,
70
+ });
71
+ }
72
+ async function listPackageJsonPaths(cwd) {
73
+ return globby('**/package.json', {
74
+ cwd,
75
+ absolute: true,
76
+ ignore: workspaceIgnorePatterns,
77
+ });
78
+ }
79
+ async function listInstalledPackages(nodeModulesPath) {
80
+ try {
81
+ const entries = await fs.readdir(nodeModulesPath, { withFileTypes: true });
82
+ const packages = new Set();
83
+ for (const entry of entries) {
84
+ if (entry.name.startsWith('.'))
85
+ continue;
86
+ if (entry.name.startsWith('@')) {
87
+ if (!entry.isDirectory())
88
+ continue;
89
+ const scopePath = path.join(nodeModulesPath, entry.name);
90
+ const scopeEntries = await fs.readdir(scopePath, {
91
+ withFileTypes: true,
92
+ });
93
+ for (const scopedEntry of scopeEntries) {
94
+ if (scopedEntry.name.startsWith('.'))
95
+ continue;
96
+ if (scopedEntry.isDirectory() || scopedEntry.isSymbolicLink()) {
97
+ packages.add(`${entry.name}/${scopedEntry.name}`);
98
+ }
99
+ }
100
+ continue;
101
+ }
102
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
103
+ packages.add(entry.name);
104
+ }
105
+ }
106
+ return packages;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ function getExpectedDependencies(dependencies) {
113
+ return Object.keys(dependencies ?? {}).sort();
114
+ }
115
+ export async function getRootPackageJsonPaths(cwd) {
116
+ const allPackageJsonPaths = await listPackageJsonPaths(cwd);
117
+ const workspacePackageJsonPaths = new Set();
118
+ for (const packageJsonPath of allPackageJsonPaths) {
119
+ const workspacePaths = await getWorkspacePackageJsonPaths(packageJsonPath);
120
+ for (const workspacePath of workspacePaths) {
121
+ workspacePackageJsonPaths.add(path.resolve(workspacePath));
122
+ }
123
+ }
124
+ return allPackageJsonPaths
125
+ .map((packageJsonPath) => path.resolve(packageJsonPath))
126
+ .filter((packageJsonPath) => !workspacePackageJsonPaths.has(packageJsonPath))
127
+ .sort();
128
+ }
129
+ export async function getRootPackageInstallStatus(packageJsonPath) {
130
+ const rootDir = path.dirname(packageJsonPath);
131
+ const packageJson = await readPackageJson(packageJsonPath);
132
+ if (!packageJson) {
133
+ return {
134
+ rootDir,
135
+ packageJsonPath,
136
+ packageManager: null,
137
+ dependencyHash: null,
138
+ dependenciesNeedInstall: false,
139
+ missingDependencies: [],
140
+ missingDevDependencies: [],
141
+ missingOptionalDependencies: [],
142
+ reason: 'package-json-unreadable',
143
+ };
144
+ }
145
+ const dependencySnapshot = getDependencySnapshot(packageJson);
146
+ const dependencyHash = hashString(JSON.stringify(dependencySnapshot));
147
+ const packageManager = parsePackageManager(packageJson.packageManager);
148
+ const dependencies = getExpectedDependencies(packageJson.dependencies);
149
+ const devDependencies = getExpectedDependencies(packageJson.devDependencies);
150
+ const optionalDependencies = getExpectedDependencies(packageJson.optionalDependencies);
151
+ const expectedDependencies = [
152
+ ...dependencies,
153
+ ...devDependencies,
154
+ ...optionalDependencies,
155
+ ];
156
+ if (expectedDependencies.length === 0) {
157
+ return {
158
+ rootDir,
159
+ packageJsonPath,
160
+ packageManager,
161
+ dependencyHash,
162
+ dependenciesNeedInstall: false,
163
+ missingDependencies: [],
164
+ missingDevDependencies: [],
165
+ missingOptionalDependencies: [],
166
+ reason: 'up-to-date',
167
+ };
168
+ }
169
+ const installedPackages = await listInstalledPackages(path.join(rootDir, 'node_modules'));
170
+ if (!installedPackages) {
171
+ return {
172
+ rootDir,
173
+ packageJsonPath,
174
+ packageManager,
175
+ dependencyHash,
176
+ dependenciesNeedInstall: true,
177
+ missingDependencies: dependencies,
178
+ missingDevDependencies: devDependencies,
179
+ missingOptionalDependencies: optionalDependencies,
180
+ reason: 'missing-node-modules',
181
+ };
182
+ }
183
+ const missingDependencies = dependencies.filter((dep) => !installedPackages.has(dep));
184
+ const missingDevDependencies = devDependencies.filter((dep) => !installedPackages.has(dep));
185
+ const missingOptionalDependencies = optionalDependencies.filter((dep) => !installedPackages.has(dep));
186
+ const dependenciesNeedInstall = missingDependencies.length > 0 || missingDevDependencies.length > 0;
187
+ return {
188
+ rootDir,
189
+ packageJsonPath,
190
+ packageManager,
191
+ dependencyHash,
192
+ dependenciesNeedInstall,
193
+ missingDependencies,
194
+ missingDevDependencies,
195
+ missingOptionalDependencies,
196
+ reason: dependenciesNeedInstall ? 'missing-dependencies' : 'up-to-date',
197
+ };
198
+ }
199
+ export async function getWorkspaceInstallStatus(cwd) {
200
+ const rootPackageJsonPaths = await getRootPackageJsonPaths(cwd);
201
+ const rootStatuses = await Promise.all(rootPackageJsonPaths.map(getRootPackageInstallStatus));
202
+ const dependenciesNeedInstall = rootStatuses.some((status) => status.dependenciesNeedInstall);
203
+ const dependencyHash = rootStatuses.length > 0
204
+ ? hashString(JSON.stringify(rootStatuses
205
+ .map((status) => ({
206
+ path: path.relative(cwd, status.packageJsonPath),
207
+ hash: status.dependencyHash,
208
+ }))
209
+ .sort((a, b) => a.path.localeCompare(b.path))))
210
+ : null;
211
+ return {
212
+ roots: rootStatuses,
213
+ dependenciesNeedInstall,
214
+ dependencyHash,
215
+ };
216
+ }
217
+ export function getInstallCommand(packageManager) {
218
+ switch (packageManager) {
219
+ case 'pnpm':
220
+ return { command: 'pnpm', args: ['install'] };
221
+ case 'yarn':
222
+ return { command: 'yarn', args: ['install'] };
223
+ case 'bun':
224
+ return { command: 'bun', args: ['install'] };
225
+ case 'npm':
226
+ default:
227
+ return { command: 'npm', args: ['install'] };
228
+ }
229
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epic-web/workshop-utils",
3
- "version": "6.71.4",
3
+ "version": "6.72.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },