-
Notifications
You must be signed in to change notification settings - Fork 10.7k
Expand file tree
/
Copy pathapp-version.ts
More file actions
172 lines (154 loc) · 6.33 KB
/
Copy pathapp-version.ts
File metadata and controls
172 lines (154 loc) · 6.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
import { readFile, stat } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join, parse as parsePath } from 'node:path';
import { releaseChannelFromVersion } from '@open-design/release';
export const APP_VERSION_FALLBACK = '0.0.0';
export const UNKNOWN_APP_VERSION = 'unknown';
// Keep this structurally aligned with `@open-design/contracts` AppVersionInfo.
// Daemon cannot import the package root type directly yet because its NodeNext
// test typecheck follows the contracts source re-exports and requires explicit
// `.js` extensions across that package.
export interface AppVersionInfo {
version: string;
channel: string;
packaged: boolean;
platform: string;
arch: string;
}
export function normalizeTelemetryAppVersion(value: unknown): string | null {
const version = cleanString(value);
return version && version !== APP_VERSION_FALLBACK && version !== UNKNOWN_APP_VERSION
? version
: null;
}
export function normalizeTelemetryAppVersionInfo(value: unknown): AppVersionInfo | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const candidate = value as Partial<AppVersionInfo>;
const version = normalizeTelemetryAppVersion(candidate.version);
const channel = cleanString(candidate.channel);
const platform = cleanString(candidate.platform);
const arch = cleanString(candidate.arch);
if (!version || !channel || !platform || !arch || typeof candidate.packaged !== 'boolean') {
return null;
}
return { version, channel, packaged: candidate.packaged, platform, arch };
}
interface PackageMetadata {
version?: unknown;
}
export interface ResolveAppVersionInfoOptions {
env?: NodeJS.ProcessEnv | undefined;
packageMetadata?: PackageMetadata | null;
resourcesPath?: string | undefined;
execPath?: string | undefined;
platform?: NodeJS.Platform | undefined;
arch?: NodeJS.Architecture | undefined;
}
export interface ReadAppVersionInfoOptions extends ResolveAppVersionInfoOptions {
packageJsonUrl?: URL | undefined;
}
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
// The compiled daemon ships in two layouts depending on which tsconfig produced
// it: `dist/app-version.js` (rootDir=src, used by the `od` CLI) and
// `dist/src/app-version.js` (rootDir=., used by the packaged sidecar entry).
// A fixed relative path like `../package.json` only points at the daemon
// `package.json` in the first layout — in the sidecar layout it resolves to
// `dist/package.json`, which does not exist, so the version silently falls
// back to `APP_VERSION_FALLBACK`. Walk up from `import.meta.url` until we find
// a real `package.json` so both build outputs (and the TypeScript source
// during `tools-dev`) read the daemon's actual version. Callers that already
// inject the version via `OD_APP_VERSION` (packaged runtime) keep working
// because that env still wins inside `resolveAppVersionInfo`.
async function findNearestPackageJsonUrl(startUrl: URL): Promise<URL | null> {
let currentDir: string;
try {
currentDir = dirname(fileURLToPath(startUrl));
} catch {
return null;
}
const root = parsePath(currentDir).root;
while (true) {
const candidate = join(currentDir, 'package.json');
try {
const stats = await stat(candidate);
if (stats.isFile()) return pathToFileURL(candidate);
} catch {
// try the parent directory
}
if (currentDir === root) return null;
const parent = dirname(currentDir);
if (parent === currentDir) return null;
currentDir = parent;
}
}
function cleanString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function inferReleaseChannelFromVersion(version: string): string | null {
return releaseChannelFromVersion(version)
?? version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/)?.[1]?.split('.')[0]
?? null;
}
export function isPackagedRuntime({
resourcesPath = processWithResources.resourcesPath,
execPath = process.execPath,
platform = process.platform,
}: Pick<ResolveAppVersionInfoOptions, 'resourcesPath' | 'execPath' | 'platform'> = {}): boolean {
if (cleanString(resourcesPath)) return true;
const normalizedExecPath = cleanString(execPath)?.replace(/\\/g, '/').toLowerCase();
if (!normalizedExecPath) return false;
switch (platform) {
case 'darwin':
return normalizedExecPath.includes('/contents/resources/');
case 'win32':
return normalizedExecPath.includes('/resources/') || normalizedExecPath.includes('/app.asar');
case 'linux':
return normalizedExecPath.includes('/usr/share/')
|| normalizedExecPath.includes('/opt/')
|| normalizedExecPath.includes('/resources/');
default:
return normalizedExecPath.includes('/resources/') || normalizedExecPath.includes('/app.asar');
}
}
export function resolveAppVersionInfo({
env = process.env,
packageMetadata,
resourcesPath,
execPath,
platform = process.platform,
arch = process.arch,
}: ResolveAppVersionInfoOptions = {}): AppVersionInfo {
const packaged = isPackagedRuntime({ resourcesPath, execPath, platform });
const version = cleanString(env.OD_APP_VERSION)
?? cleanString(packageMetadata?.version)
?? APP_VERSION_FALLBACK;
const inferredChannel = inferReleaseChannelFromVersion(version);
const channel = cleanString(env.OD_RELEASE_CHANNEL)
?? cleanString(env.OD_APP_CHANNEL)
?? inferredChannel
?? (packaged ? 'stable' : 'development');
return { version, channel, packaged, platform, arch };
}
async function readPackageMetadata(packageJsonUrl: URL): Promise<PackageMetadata | null> {
try {
const raw = await readFile(packageJsonUrl, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
export async function readCurrentAppVersionInfo({
packageJsonUrl,
packageMetadata,
env,
resourcesPath,
execPath,
platform,
arch,
}: ReadAppVersionInfoOptions = {}): Promise<AppVersionInfo> {
const resolvedUrl = packageJsonUrl ?? await findNearestPackageJsonUrl(new URL(import.meta.url));
const metadata = packageMetadata
?? (resolvedUrl ? await readPackageMetadata(resolvedUrl) : null);
return resolveAppVersionInfo({ env, packageMetadata: metadata, resourcesPath, execPath, platform, arch });
}