@crossplatformai/dependency-graph 0.9.2
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/LICENSE +7 -0
- package/README.md +204 -0
- package/package.json +57 -0
- package/src/cli/pr-preview.ts +388 -0
- package/src/cli/validate-workflows.ts +822 -0
- package/src/graph/analysis.ts +147 -0
- package/src/graph/builder.ts +52 -0
- package/src/graph/traversal.ts +132 -0
- package/src/graph/types.ts +50 -0
- package/src/index.test.ts +94 -0
- package/src/index.ts +40 -0
- package/src/types/clients.ts +19 -0
- package/src/workspace/discovery.ts +94 -0
- package/src/workspace/file-mapping.ts +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2024-2026 CrossPlatform.ai. All Rights Reserved.
|
|
2
|
+
|
|
3
|
+
This software is proprietary and confidential. Unauthorized copying, distribution,
|
|
4
|
+
modification, or use of this software, via any medium, is strictly prohibited
|
|
5
|
+
without the express written permission of CrossPlatform.ai.
|
|
6
|
+
|
|
7
|
+
For licensing inquiries, contact: legal@crossplatform.ai
|
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# @repo/dependency-graph
|
|
2
|
+
|
|
3
|
+
Shared utility plugin for workspace dependency graph analysis. Provides workspace discovery, graph building, traversal, and analysis utilities.
|
|
4
|
+
|
|
5
|
+
## Zero Dependencies
|
|
6
|
+
|
|
7
|
+
This plugin follows the **zero dependencies** pattern. It defines interfaces for external services (`FileSystemClient`, `GlobClient`, `YamlClient`) and accepts implementations via dependency injection.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @repo/dependency-graph
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### Discovering Workspaces
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { discoverWorkspaces } from '@repo/dependency-graph';
|
|
21
|
+
import { readFile } from 'node:fs/promises';
|
|
22
|
+
import { glob } from 'glob';
|
|
23
|
+
import { parse } from 'yaml';
|
|
24
|
+
|
|
25
|
+
const packages = await discoverWorkspaces(process.cwd(), {
|
|
26
|
+
fs: {
|
|
27
|
+
readFile: async (path, encoding) => readFile(path, encoding),
|
|
28
|
+
exists: async (path) => {
|
|
29
|
+
try {
|
|
30
|
+
await readFile(path);
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
glob: {
|
|
38
|
+
glob: async (pattern, options) => glob(pattern, options),
|
|
39
|
+
},
|
|
40
|
+
yaml: {
|
|
41
|
+
parse: (content) => parse(content),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Building Dependency Graph
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import { buildDependencyGraph } from '@repo/dependency-graph';
|
|
50
|
+
|
|
51
|
+
const graph = buildDependencyGraph(packages);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Finding Affected Packages
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import { findAffectedPackages } from '@repo/dependency-graph';
|
|
58
|
+
|
|
59
|
+
const affected = findAffectedPackages(
|
|
60
|
+
graph,
|
|
61
|
+
'my-package',
|
|
62
|
+
{ includeSelf: true }
|
|
63
|
+
);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Detecting Cycles
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { detectCycles } from '@repo/dependency-graph';
|
|
70
|
+
|
|
71
|
+
const cycles = detectCycles(graph);
|
|
72
|
+
if (cycles.length > 0) {
|
|
73
|
+
console.error('Circular dependencies detected:', cycles);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Mapping Files to Packages
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { mapFilesToPackages } from '@repo/dependency-graph';
|
|
81
|
+
|
|
82
|
+
const changedFiles = ['apps/web/src/index.ts', 'packages/ui/src/button.tsx'];
|
|
83
|
+
const fileMap = mapFilesToPackages(changedFiles, packages);
|
|
84
|
+
|
|
85
|
+
console.log(fileMap);
|
|
86
|
+
// Map { 'web' => ['apps/web/src/index.ts'], '@repo/shared' => ['packages/ui/src/button.tsx'] }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## API
|
|
90
|
+
|
|
91
|
+
### Types
|
|
92
|
+
|
|
93
|
+
- `WorkspacePackage` - Package metadata from package.json
|
|
94
|
+
- `DependencyGraph` - Graph representation of package dependencies
|
|
95
|
+
- `DependencyNode` - Node in the dependency graph
|
|
96
|
+
- `GraphStats` - Statistics about the dependency graph
|
|
97
|
+
|
|
98
|
+
### Client Interfaces (Dependency Injection)
|
|
99
|
+
|
|
100
|
+
- `FileSystemClient` - File system operations interface
|
|
101
|
+
- `GlobClient` - Glob pattern matching interface
|
|
102
|
+
- `YamlClient` - YAML parsing interface
|
|
103
|
+
|
|
104
|
+
### Functions
|
|
105
|
+
|
|
106
|
+
#### Workspace Discovery
|
|
107
|
+
|
|
108
|
+
- `discoverWorkspaces(rootDir, config)` - Discover all workspace packages
|
|
109
|
+
|
|
110
|
+
#### Graph Building
|
|
111
|
+
|
|
112
|
+
- `buildDependencyGraph(packages)` - Build dependency graph from packages
|
|
113
|
+
|
|
114
|
+
#### Graph Traversal
|
|
115
|
+
|
|
116
|
+
- `findAffectedPackages(graph, packageName, options)` - Find all packages affected by changes
|
|
117
|
+
- `findDependencyPath(graph, from, to)` - Find shortest path between packages
|
|
118
|
+
- `findAllPaths(graph, from, to)` - Find all paths between packages
|
|
119
|
+
|
|
120
|
+
#### Graph Analysis
|
|
121
|
+
|
|
122
|
+
- `analyzeGraph(graph)` - Get comprehensive graph statistics
|
|
123
|
+
- `detectCycles(graph)` - Detect circular dependencies
|
|
124
|
+
- `getTransitiveDependencies(graph, packageName)` - Get all transitive dependencies
|
|
125
|
+
- `getTransitiveDependents(graph, packageName)` - Get all transitive dependents
|
|
126
|
+
|
|
127
|
+
#### File Mapping
|
|
128
|
+
|
|
129
|
+
- `findPackageForFile(filePath, packages)` - Find which package owns a file
|
|
130
|
+
- `mapFilesToPackages(files, packages)` - Map array of files to their packages
|
|
131
|
+
|
|
132
|
+
## Dependency Injection Pattern
|
|
133
|
+
|
|
134
|
+
This plugin requires clients to be provided by the host application:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import type { WorkspaceDiscoveryConfig } from '@repo/dependency-graph';
|
|
138
|
+
import { readFile } from 'node:fs/promises';
|
|
139
|
+
import { glob } from 'glob';
|
|
140
|
+
import { parse as parseYaml } from 'yaml';
|
|
141
|
+
|
|
142
|
+
// Create config with real implementations
|
|
143
|
+
const config: WorkspaceDiscoveryConfig = {
|
|
144
|
+
fs: {
|
|
145
|
+
readFile: (path, encoding) => readFile(path, encoding),
|
|
146
|
+
exists: async (path) => {
|
|
147
|
+
try {
|
|
148
|
+
await readFile(path);
|
|
149
|
+
return true;
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
glob: {
|
|
156
|
+
glob: (pattern, options) => glob(pattern, options),
|
|
157
|
+
},
|
|
158
|
+
yaml: {
|
|
159
|
+
parse: (content) => parseYaml(content),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Use with dependency injection
|
|
164
|
+
const packages = await discoverWorkspaces(process.cwd(), config);
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Testing
|
|
168
|
+
|
|
169
|
+
For testing, provide mock implementations:
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
173
|
+
|
|
174
|
+
const mockConfig = {
|
|
175
|
+
fs: {
|
|
176
|
+
readFile: vi.fn(),
|
|
177
|
+
exists: vi.fn(),
|
|
178
|
+
},
|
|
179
|
+
glob: {
|
|
180
|
+
glob: vi.fn(),
|
|
181
|
+
},
|
|
182
|
+
yaml: {
|
|
183
|
+
parse: vi.fn(),
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Mock implementations
|
|
188
|
+
mockConfig.fs.readFile.mockResolvedValue('{}');
|
|
189
|
+
mockConfig.glob.glob.mockResolvedValue(['apps/web', 'packages/ui']);
|
|
190
|
+
mockConfig.yaml.parse.mockReturnValue({ packages: ['apps/*', 'packages/*'] });
|
|
191
|
+
|
|
192
|
+
const packages = await discoverWorkspaces('/fake/root', mockConfig);
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Why Zero Dependencies?
|
|
196
|
+
|
|
197
|
+
- **Flexibility**: Use any file system, glob, or YAML library
|
|
198
|
+
- **Version Control**: Host controls all dependency versions
|
|
199
|
+
- **Testing**: Easy to mock with simple interfaces
|
|
200
|
+
- **Reusability**: Works across different environments (Node.js, Bun, Deno, etc.)
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
Apache-2.0
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crossplatformai/dependency-graph",
|
|
3
|
+
"description": "Shared dependency graph plugin for CrossPlatform.ai projects",
|
|
4
|
+
"version": "0.9.2",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/crossplatformai/dependency-graph.git"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./src/index.ts",
|
|
23
|
+
"import": "./src/index.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"check-types": "tsc --noEmit --skipLibCheck",
|
|
28
|
+
"format": "prettier --write \"src/**/*.ts\" \"*.json\"",
|
|
29
|
+
"format:check": "prettier --check \"src/**/*.ts\" \"*.json\"",
|
|
30
|
+
"lint": "eslint . --cache --fix --max-warnings 0",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"publish:next": "npm publish --tag next",
|
|
34
|
+
"publish:production": "npm publish --tag latest"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"glob": "^11.0.1",
|
|
38
|
+
"yaml": "^2.7.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@crossplatformai/prettier-config": "^0.0.2",
|
|
42
|
+
"@crossplatformai/typescript-config": "^0.6.3",
|
|
43
|
+
"@eslint/js": "^9.39.2",
|
|
44
|
+
"@types/node": "^22.13.0",
|
|
45
|
+
"eslint": "^9.21.0",
|
|
46
|
+
"eslint-config-prettier": "^10.0.0",
|
|
47
|
+
"eslint-plugin-import": "^2.31.0",
|
|
48
|
+
"eslint-plugin-prettier": "^5.2.1",
|
|
49
|
+
"globals": "^15.13.0",
|
|
50
|
+
"prettier": "^3.4.2",
|
|
51
|
+
"typescript": "^5.8.2",
|
|
52
|
+
"typescript-eslint": "^8.18.0",
|
|
53
|
+
"vitest": "^3.0.6"
|
|
54
|
+
},
|
|
55
|
+
"prettier": "@crossplatformai/prettier-config",
|
|
56
|
+
"packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc"
|
|
57
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { glob } from 'glob';
|
|
6
|
+
import { parse as parseYaml } from 'yaml';
|
|
7
|
+
|
|
8
|
+
// Import dependency graph functions from parent module
|
|
9
|
+
import {
|
|
10
|
+
discoverWorkspaces,
|
|
11
|
+
buildDependencyGraph,
|
|
12
|
+
findAffectedPackages,
|
|
13
|
+
type WorkspacePackage,
|
|
14
|
+
} from '../index.js';
|
|
15
|
+
|
|
16
|
+
interface ChangedFile {
|
|
17
|
+
path: string;
|
|
18
|
+
status: 'modified' | 'added' | 'deleted';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface DeploymentIndicator {
|
|
22
|
+
file?: string;
|
|
23
|
+
field?: string;
|
|
24
|
+
dependency?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PlatformDetection {
|
|
28
|
+
platform: string;
|
|
29
|
+
indicators: DeploymentIndicator[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const WORKFLOW_MAPPING: Record<string, string> = {
|
|
33
|
+
'deploy-web.yml': 'web',
|
|
34
|
+
'deploy-api-origin.yml': 'api-origin',
|
|
35
|
+
'deploy-api-edge.yml': 'api-edge',
|
|
36
|
+
'release-mobile.yml': 'mobile',
|
|
37
|
+
'release-desktop.yml': 'desktop',
|
|
38
|
+
'release-cli.yml': 'cli',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Convention-based deployment detection (order matters - most specific first)
|
|
42
|
+
const PLATFORM_INDICATORS: PlatformDetection[] = [
|
|
43
|
+
{
|
|
44
|
+
platform: 'cloudflare-workers',
|
|
45
|
+
indicators: [{ file: 'wrangler.toml' }],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
platform: 'expo',
|
|
49
|
+
indicators: [{ file: 'app.json' }, { file: 'eas.json' }],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
platform: 'npm-package',
|
|
53
|
+
indicators: [{ field: 'bin' }],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
platform: 'electron',
|
|
57
|
+
indicators: [
|
|
58
|
+
{ dependency: 'electron' },
|
|
59
|
+
{ dependency: 'electron-builder' },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
platform: 'next.js',
|
|
64
|
+
indicators: [
|
|
65
|
+
{ file: 'next.config.js' },
|
|
66
|
+
{ file: 'next.config.mjs' },
|
|
67
|
+
{ file: 'next.config.ts' },
|
|
68
|
+
{ dependency: 'next' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
platform: 'node.js',
|
|
73
|
+
indicators: [
|
|
74
|
+
{ field: 'start' }, // has start script
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const DEFAULT_APP_DIRECTORIES = ['apps'];
|
|
80
|
+
|
|
81
|
+
function isDeployableApp(pkg: WorkspacePackage): {
|
|
82
|
+
deployable: boolean;
|
|
83
|
+
platform?: string;
|
|
84
|
+
} {
|
|
85
|
+
// Check if package is in apps directory (configurable in future)
|
|
86
|
+
const isInAppDirectory = DEFAULT_APP_DIRECTORIES.some(
|
|
87
|
+
(dir) => pkg.path.includes(`/${dir}/`) || pkg.path.endsWith(`/${dir}`),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (!isInAppDirectory) {
|
|
91
|
+
return { deployable: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Detect platform based on indicators
|
|
95
|
+
for (const platformDetection of PLATFORM_INDICATORS) {
|
|
96
|
+
const hasIndicators = platformDetection.indicators.some((indicator) => {
|
|
97
|
+
if (indicator.file) {
|
|
98
|
+
try {
|
|
99
|
+
const filePath = resolve(pkg.path, indicator.file);
|
|
100
|
+
readFileSync(filePath, 'utf-8');
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (indicator.field) {
|
|
108
|
+
const scripts = pkg.packageJson.scripts as
|
|
109
|
+
| Record<string, string>
|
|
110
|
+
| undefined;
|
|
111
|
+
if (indicator.field === 'start' && scripts?.start) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
const packageJsonField =
|
|
115
|
+
pkg.packageJson[indicator.field as keyof typeof pkg.packageJson];
|
|
116
|
+
if (indicator.field !== 'start' && packageJsonField) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (indicator.dependency) {
|
|
122
|
+
return !!(
|
|
123
|
+
pkg.dependencies[indicator.dependency] ||
|
|
124
|
+
pkg.devDependencies[indicator.dependency]
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return false;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (hasIndicators) {
|
|
132
|
+
return { deployable: true, platform: platformDetection.platform };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// If in apps directory but no specific platform detected, not deployable
|
|
137
|
+
return { deployable: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function getChangedFiles(): Promise<ChangedFile[]> {
|
|
141
|
+
const { execSync } = await import('node:child_process');
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
// Try local production first, then fall back to origin/production
|
|
145
|
+
let baseBranch = 'production';
|
|
146
|
+
try {
|
|
147
|
+
execSync('git rev-parse production', {
|
|
148
|
+
encoding: 'utf-8',
|
|
149
|
+
stdio: 'pipe',
|
|
150
|
+
});
|
|
151
|
+
} catch {
|
|
152
|
+
baseBranch = 'origin/production';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const output = execSync(`git diff --name-status ${baseBranch}...HEAD`, {
|
|
156
|
+
encoding: 'utf-8',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return output
|
|
160
|
+
.trim()
|
|
161
|
+
.split('\n')
|
|
162
|
+
.filter((line) => line)
|
|
163
|
+
.map((line) => {
|
|
164
|
+
const [status, path] = line.split('\t');
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
path: path ?? '',
|
|
168
|
+
status:
|
|
169
|
+
status === 'D' ? 'deleted' : status === 'A' ? 'added' : 'modified',
|
|
170
|
+
};
|
|
171
|
+
})
|
|
172
|
+
.filter((file): file is ChangedFile => file.path !== '');
|
|
173
|
+
} catch {
|
|
174
|
+
console.error(
|
|
175
|
+
'Failed to get changed files. Ensure you are on a branch with commits compared to production.',
|
|
176
|
+
);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function main() {
|
|
182
|
+
try {
|
|
183
|
+
const rootDir = resolve(process.cwd());
|
|
184
|
+
const changedFiles = await getChangedFiles();
|
|
185
|
+
|
|
186
|
+
console.log(
|
|
187
|
+
`\nš¦ Release Preview - Changed Files: ${changedFiles.length}\n`,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
changedFiles.forEach((file) => {
|
|
191
|
+
console.log(` ${file.status === 'deleted' ? 'ā' : 'š'} ${file.path}`);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const packages = await discoverWorkspaces(rootDir, {
|
|
195
|
+
fs: {
|
|
196
|
+
readFile: (path) => {
|
|
197
|
+
try {
|
|
198
|
+
return Promise.resolve(readFileSync(path, 'utf-8'));
|
|
199
|
+
} catch {
|
|
200
|
+
return Promise.reject(new Error(`Failed to read file: ${path}`));
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
exists: (path) => {
|
|
204
|
+
try {
|
|
205
|
+
readFileSync(path, 'utf-8');
|
|
206
|
+
return Promise.resolve(true);
|
|
207
|
+
} catch {
|
|
208
|
+
return Promise.resolve(false);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
glob: {
|
|
213
|
+
glob: async (pattern, options) => glob(pattern, options),
|
|
214
|
+
},
|
|
215
|
+
yaml: {
|
|
216
|
+
parse: (content) => parseYaml(content) as Record<string, unknown>,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const graph = buildDependencyGraph(packages);
|
|
221
|
+
const changedPackages = new Set<string>();
|
|
222
|
+
|
|
223
|
+
for (const file of changedFiles) {
|
|
224
|
+
if (file.status === 'deleted') continue;
|
|
225
|
+
|
|
226
|
+
// Convert absolute package paths to relative for comparison
|
|
227
|
+
const pkg = packages.find((p) => {
|
|
228
|
+
const relativePkgPath = p.path.replace(rootDir + '/', '');
|
|
229
|
+
return file.path.startsWith(relativePkgPath);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (pkg) {
|
|
233
|
+
changedPackages.add(pkg.name);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const changedWorkflows = changedFiles.filter((f) =>
|
|
238
|
+
f.path.startsWith('.github/workflows/'),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const workflowAffectedApps = new Set<string>();
|
|
242
|
+
for (const workflow of changedWorkflows) {
|
|
243
|
+
const workflowName = workflow.path.split('/').pop();
|
|
244
|
+
if (workflowName && WORKFLOW_MAPPING[workflowName]) {
|
|
245
|
+
workflowAffectedApps.add(WORKFLOW_MAPPING[workflowName]);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.log(
|
|
250
|
+
`\nš¦ Changed packages: ${Array.from(changedPackages).join(', ') || 'none'}\n`,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
if (changedPackages.size === 0) {
|
|
254
|
+
console.log('\n⨠No packages changed - no deployments needed\n');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const affected = findAffectedPackages(changedPackages, graph, {
|
|
259
|
+
direction: 'upstream',
|
|
260
|
+
respectAffectsUpstream: true,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Find all deployable apps (affected and unaffected)
|
|
264
|
+
const allDeployableApps = packages
|
|
265
|
+
.map((pkg) => {
|
|
266
|
+
const detection = isDeployableApp(pkg);
|
|
267
|
+
return detection.deployable
|
|
268
|
+
? { name: pkg.name, pkg, platform: detection.platform }
|
|
269
|
+
: null;
|
|
270
|
+
})
|
|
271
|
+
.filter(
|
|
272
|
+
(
|
|
273
|
+
item,
|
|
274
|
+
): item is {
|
|
275
|
+
name: string;
|
|
276
|
+
pkg: WorkspacePackage;
|
|
277
|
+
platform: string | undefined;
|
|
278
|
+
} => item !== null,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// Categorize apps by deployment type
|
|
282
|
+
const affectedApps = allDeployableApps.filter(
|
|
283
|
+
(app): app is NonNullable<typeof app> =>
|
|
284
|
+
affected.has(app.name) || workflowAffectedApps.has(app.name),
|
|
285
|
+
);
|
|
286
|
+
const unaffectedApps = allDeployableApps.filter(
|
|
287
|
+
(app): app is NonNullable<typeof app> =>
|
|
288
|
+
!affected.has(app.name) && !workflowAffectedApps.has(app.name),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// Separate deploys (web-based) vs releases (installed)
|
|
292
|
+
const getAppCategory = (platform?: string) => {
|
|
293
|
+
switch (platform) {
|
|
294
|
+
case 'next.js':
|
|
295
|
+
case 'cloudflare-workers':
|
|
296
|
+
case 'node.js':
|
|
297
|
+
return 'deploy';
|
|
298
|
+
case 'expo':
|
|
299
|
+
case 'electron':
|
|
300
|
+
case 'npm-package':
|
|
301
|
+
return 'release';
|
|
302
|
+
default:
|
|
303
|
+
return 'deploy'; // default to deploy for generic
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const getAppIcon = (platform?: string) => {
|
|
308
|
+
switch (platform) {
|
|
309
|
+
case 'next.js':
|
|
310
|
+
case 'cloudflare-workers':
|
|
311
|
+
case 'node.js':
|
|
312
|
+
return 'š';
|
|
313
|
+
case 'expo':
|
|
314
|
+
return 'š±';
|
|
315
|
+
case 'electron':
|
|
316
|
+
return 'š„ļø';
|
|
317
|
+
case 'npm-package':
|
|
318
|
+
return 'ā”';
|
|
319
|
+
default:
|
|
320
|
+
return 'š';
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const affectedDeploys = affectedApps.filter(
|
|
325
|
+
(app): app is NonNullable<typeof app> =>
|
|
326
|
+
getAppCategory(app.platform) === 'deploy',
|
|
327
|
+
);
|
|
328
|
+
const affectedReleases = affectedApps.filter(
|
|
329
|
+
(app): app is NonNullable<typeof app> =>
|
|
330
|
+
getAppCategory(app.platform) === 'release',
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
console.log(`\nš PR Preview\n`);
|
|
334
|
+
|
|
335
|
+
if (affectedApps.length === 0) {
|
|
336
|
+
console.log('No apps affected by changes.\n');
|
|
337
|
+
} else {
|
|
338
|
+
if (affectedDeploys.length > 0) {
|
|
339
|
+
console.log('š Apps that will be DEPLOYED:');
|
|
340
|
+
for (const item of affectedDeploys) {
|
|
341
|
+
const platformDisplay =
|
|
342
|
+
item.platform === 'generic' ? '' : ` (${item.platform})`;
|
|
343
|
+
const icon = getAppIcon(item.platform);
|
|
344
|
+
console.log(`${icon} ${item.name}${platformDisplay}`);
|
|
345
|
+
}
|
|
346
|
+
console.log('');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (affectedReleases.length > 0) {
|
|
350
|
+
console.log('š Apps that will be RELEASED:');
|
|
351
|
+
for (const item of affectedReleases) {
|
|
352
|
+
const platformDisplay =
|
|
353
|
+
item.platform === 'generic' ? '' : ` (${item.platform})`;
|
|
354
|
+
const icon = getAppIcon(item.platform);
|
|
355
|
+
console.log(`${icon} ${item.name}${platformDisplay}`);
|
|
356
|
+
}
|
|
357
|
+
console.log('');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (unaffectedApps.length > 0) {
|
|
362
|
+
console.log(`š Apps that will NOT be affected:`);
|
|
363
|
+
for (const item of unaffectedApps) {
|
|
364
|
+
const platformDisplay =
|
|
365
|
+
item.platform === 'generic' ? '' : ` (${item.platform})`;
|
|
366
|
+
console.log(`āļø ${item.name}${platformDisplay}`);
|
|
367
|
+
}
|
|
368
|
+
console.log('');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
console.log(`Legend:`);
|
|
372
|
+
console.log(`š = Deploy (web-based, instant updates)`);
|
|
373
|
+
console.log(`š± = Release (mobile app, user installs)`);
|
|
374
|
+
console.log(`š„ļø = Release (desktop app, user installs)`);
|
|
375
|
+
console.log(`ā” = Release (CLI tool, user installs)`);
|
|
376
|
+
console.log(`āļø = Unaffected (no changes needed)`);
|
|
377
|
+
console.log(`š = File changed`);
|
|
378
|
+
console.log(`ā = File deleted\n`);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.error(
|
|
381
|
+
'Error:',
|
|
382
|
+
error instanceof Error ? error.message : String(error),
|
|
383
|
+
);
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
void main();
|