ether-to-astro 1.0.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.
- package/.env.example +13 -0
- package/.github/pull_request_template.md +16 -0
- package/.github/workflows/release.yml +35 -0
- package/.github/workflows/test.yml +32 -0
- package/AGENTS.md +99 -0
- package/LICENSE +18 -0
- package/NOTICE.md +45 -0
- package/README.md +301 -0
- package/SETUP.md +70 -0
- package/TESTING_SUMMARY.md +238 -0
- package/TEST_SUITE_STATUS.md +218 -0
- package/biome.json +48 -0
- package/dist/astro-service.d.ts +98 -0
- package/dist/astro-service.js +496 -0
- package/dist/chart-types.d.ts +52 -0
- package/dist/chart-types.js +51 -0
- package/dist/charts.d.ts +125 -0
- package/dist/charts.js +324 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +472 -0
- package/dist/constants.d.ts +81 -0
- package/dist/constants.js +76 -0
- package/dist/eclipses.d.ts +85 -0
- package/dist/eclipses.js +184 -0
- package/dist/ephemeris.d.ts +120 -0
- package/dist/ephemeris.js +379 -0
- package/dist/formatter.d.ts +2 -0
- package/dist/formatter.js +22 -0
- package/dist/houses.d.ts +82 -0
- package/dist/houses.js +169 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +150 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +31 -0
- package/dist/logger.d.ts +25 -0
- package/dist/logger.js +73 -0
- package/dist/profile-store.d.ts +48 -0
- package/dist/profile-store.js +156 -0
- package/dist/riseset.d.ts +82 -0
- package/dist/riseset.js +185 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +40 -0
- package/dist/time-utils.d.ts +68 -0
- package/dist/time-utils.js +136 -0
- package/dist/tool-registry.d.ts +35 -0
- package/dist/tool-registry.js +307 -0
- package/dist/tool-result.d.ts +175 -0
- package/dist/tool-result.js +188 -0
- package/dist/transits.d.ts +108 -0
- package/dist/transits.js +263 -0
- package/dist/types.d.ts +450 -0
- package/dist/types.js +161 -0
- package/example-usage.md +131 -0
- package/natal-chart.json +187 -0
- package/package.json +61 -0
- package/scripts/download-ephemeris.js +115 -0
- package/setup.sh +21 -0
- package/src/astro-service.ts +710 -0
- package/src/chart-types.ts +125 -0
- package/src/charts.ts +399 -0
- package/src/cli.ts +694 -0
- package/src/constants.ts +89 -0
- package/src/eclipses.ts +226 -0
- package/src/ephemeris.ts +437 -0
- package/src/formatter.ts +25 -0
- package/src/houses.ts +202 -0
- package/src/index.ts +170 -0
- package/src/loader.ts +36 -0
- package/src/logger.ts +104 -0
- package/src/profile-store.ts +285 -0
- package/src/riseset.ts +229 -0
- package/src/time-utils.ts +167 -0
- package/src/tool-registry.ts +357 -0
- package/src/tool-result.ts +283 -0
- package/src/transits.ts +352 -0
- package/src/types.ts +547 -0
- package/tests/README.md +173 -0
- package/tests/TESTING_STRATEGY.md +178 -0
- package/tests/fixtures/bowen-yang-chart.ts +69 -0
- package/tests/fixtures/calculate-expected.ts +81 -0
- package/tests/fixtures/expected-results.ts +117 -0
- package/tests/fixtures/generate-expected-simple.ts +94 -0
- package/tests/helpers/date-fixtures.ts +15 -0
- package/tests/helpers/ephem.ts +11 -0
- package/tests/helpers/temp.ts +9 -0
- package/tests/setup.ts +11 -0
- package/tests/unit/astro-service.test.ts +323 -0
- package/tests/unit/chart-types.test.ts +18 -0
- package/tests/unit/charts-errors.test.ts +42 -0
- package/tests/unit/charts.test.ts +157 -0
- package/tests/unit/cli-commands.test.ts +82 -0
- package/tests/unit/cli-profiles.test.ts +128 -0
- package/tests/unit/cli.test.ts +191 -0
- package/tests/unit/constants.test.ts +26 -0
- package/tests/unit/correctness-critical.test.ts +408 -0
- package/tests/unit/eclipses.test.ts +108 -0
- package/tests/unit/ephemeris.test.ts +213 -0
- package/tests/unit/error-handling.test.ts +116 -0
- package/tests/unit/formatter.test.ts +29 -0
- package/tests/unit/houses-errors.test.ts +27 -0
- package/tests/unit/houses-validation.test.ts +164 -0
- package/tests/unit/houses.test.ts +205 -0
- package/tests/unit/profile-store.test.ts +163 -0
- package/tests/unit/real-user-charts.test.ts +148 -0
- package/tests/unit/riseset.test.ts +106 -0
- package/tests/unit/solver-edges.test.ts +197 -0
- package/tests/unit/time-utils-temporal.test.ts +303 -0
- package/tests/unit/time-utils.test.ts +173 -0
- package/tests/unit/tool-registry.test.ts +222 -0
- package/tests/unit/tool-result.test.ts +45 -0
- package/tests/unit/transit-correctness.test.ts +78 -0
- package/tests/unit/transits.test.ts +238 -0
- package/tests/validation/README.md +32 -0
- package/tests/validation/adapters/astrolog.ts +306 -0
- package/tests/validation/adapters/internal.ts +184 -0
- package/tests/validation/compare/eclipses.ts +47 -0
- package/tests/validation/compare/houses.ts +76 -0
- package/tests/validation/compare/positions.ts +104 -0
- package/tests/validation/compare/riseSet.ts +48 -0
- package/tests/validation/compare/roots.ts +90 -0
- package/tests/validation/compare/transits.ts +69 -0
- package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
- package/tests/validation/fixtures/eclipses/core.ts +14 -0
- package/tests/validation/fixtures/houses/core.ts +47 -0
- package/tests/validation/fixtures/positions/core.ts +159 -0
- package/tests/validation/fixtures/rise-set/core.ts +20 -0
- package/tests/validation/fixtures/roots/core.ts +47 -0
- package/tests/validation/fixtures/transits/core.ts +61 -0
- package/tests/validation/fixtures/transits/dst.ts +21 -0
- package/tests/validation/oracle.spec.ts +129 -0
- package/tests/validation/utils/denseRootOracle.ts +269 -0
- package/tests/validation/utils/fixtureTypes.ts +146 -0
- package/tests/validation/utils/report.ts +60 -0
- package/tests/validation/utils/tolerances.ts +23 -0
- package/tests/validation/validation.spec.ts +836 -0
- package/tools/color-picker.html +388 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +31 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main MCP server for astrological calculations
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Provides tools for:
|
|
6
|
+
* - Setting and managing natal charts
|
|
7
|
+
* - Calculating planetary positions and transits
|
|
8
|
+
* - Generating astrological charts
|
|
9
|
+
* - Computing houses, rise/set times, and eclipses
|
|
10
|
+
*
|
|
11
|
+
* Uses Swiss Ephemeris for accurate astronomical calculations.
|
|
12
|
+
* All calculations are tropical (not sidereal) and geocentric.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
16
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
17
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
18
|
+
import { AstroService } from './astro-service.js';
|
|
19
|
+
import { logger } from './logger.js';
|
|
20
|
+
import { getToolSpec, MCP_TOOL_SPECS } from './tool-registry.js';
|
|
21
|
+
import { mcpError, mcpResult, missingNatalChart } from './tool-result.js';
|
|
22
|
+
import type { NatalChart } from './types.js';
|
|
23
|
+
|
|
24
|
+
const server = new Server(
|
|
25
|
+
{
|
|
26
|
+
name: 'e2a-mcp',
|
|
27
|
+
version: '1.0.0',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
capabilities: {
|
|
31
|
+
tools: {},
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* In-memory natal chart — the server's sole piece of mutable state.
|
|
38
|
+
*
|
|
39
|
+
* Lifecycle:
|
|
40
|
+
* - Starts as `null` when the process launches.
|
|
41
|
+
* - Set by `set_natal_chart`; overwritten on each call.
|
|
42
|
+
* - Persists for the lifetime of this stdio process (one per MCP client).
|
|
43
|
+
* - Tools that require it (`get_transits`, `get_houses`, `get_rise_set_times`,
|
|
44
|
+
* `generate_natal_chart`, `generate_transit_chart`) return a structured
|
|
45
|
+
* MISSING_NATAL_CHART error if it is null.
|
|
46
|
+
* - Use `get_server_status` to inspect whether a chart is loaded.
|
|
47
|
+
*
|
|
48
|
+
* Thread safety: Each MCP client connection spawns a separate Node.js process
|
|
49
|
+
* via stdio transport, so this variable is isolated per client.
|
|
50
|
+
* No synchronization needed as requests are serialized within a single process.
|
|
51
|
+
*/
|
|
52
|
+
let natalChart: NatalChart | null = null;
|
|
53
|
+
|
|
54
|
+
// Calculator instances (initialized on demand)
|
|
55
|
+
const astroService = new AstroService();
|
|
56
|
+
|
|
57
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
58
|
+
return {
|
|
59
|
+
tools: MCP_TOOL_SPECS.map((spec) => ({
|
|
60
|
+
name: spec.name,
|
|
61
|
+
description: spec.description,
|
|
62
|
+
inputSchema: spec.inputSchema,
|
|
63
|
+
})),
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Handle MCP tool requests
|
|
69
|
+
*
|
|
70
|
+
* @param request - The MCP tool request
|
|
71
|
+
* @returns Tool response with data or error
|
|
72
|
+
* @throws Error for unhandled tools
|
|
73
|
+
*
|
|
74
|
+
* @remarks
|
|
75
|
+
* Routes requests to appropriate handlers. Initializes ephemeris on first use.
|
|
76
|
+
* All handlers return structured responses suitable for MCP clients.
|
|
77
|
+
*/
|
|
78
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
79
|
+
const { name, arguments: args = {} } = request.params;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const spec = getToolSpec(name);
|
|
83
|
+
if (!spec) {
|
|
84
|
+
return mcpError({
|
|
85
|
+
code: 'INVALID_INPUT',
|
|
86
|
+
message: `Unknown tool: ${name}`,
|
|
87
|
+
retryable: false,
|
|
88
|
+
suggestedFix: 'Check the tool name against the list returned by ListTools.',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (spec.requiresNatalChart && !natalChart) {
|
|
93
|
+
return mcpError(missingNatalChart());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result = await spec.execute(
|
|
97
|
+
{
|
|
98
|
+
service: astroService,
|
|
99
|
+
natalChart,
|
|
100
|
+
},
|
|
101
|
+
args as Record<string, unknown>
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (result.kind === 'state') {
|
|
105
|
+
if (result.natalChart !== undefined) {
|
|
106
|
+
natalChart = result.natalChart;
|
|
107
|
+
}
|
|
108
|
+
return mcpResult(result.data, result.text);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { content: result.content };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
114
|
+
|
|
115
|
+
// Map known error patterns to appropriate codes
|
|
116
|
+
let code: import('./tool-result.js').ToolIssueCode;
|
|
117
|
+
if (
|
|
118
|
+
errorMessage.includes('Invalid date format') ||
|
|
119
|
+
errorMessage.includes('Invalid calendar date') ||
|
|
120
|
+
errorMessage.includes('Invalid month') ||
|
|
121
|
+
errorMessage.includes('Invalid day') ||
|
|
122
|
+
errorMessage.includes('days_ahead') ||
|
|
123
|
+
errorMessage.includes('max_orb') ||
|
|
124
|
+
errorMessage.includes('missing julianDay')
|
|
125
|
+
) {
|
|
126
|
+
code = 'INVALID_INPUT';
|
|
127
|
+
} else if (errorMessage.includes('Invalid timezone') || errorMessage.includes('timezone')) {
|
|
128
|
+
code = 'INVALID_TIMEZONE';
|
|
129
|
+
} else if (errorMessage.includes('Invalid house system')) {
|
|
130
|
+
code = 'INVALID_HOUSE_SYSTEM';
|
|
131
|
+
} else if (errorMessage.includes('Ephemeris') || errorMessage.includes('ephemeris')) {
|
|
132
|
+
code = 'EPHEMERIS_COMPUTE_FAILED';
|
|
133
|
+
} else if (
|
|
134
|
+
errorMessage.includes('write') ||
|
|
135
|
+
errorMessage.includes('ENOENT') ||
|
|
136
|
+
errorMessage.includes('EACCES')
|
|
137
|
+
) {
|
|
138
|
+
code = 'FILE_WRITE_FAILED';
|
|
139
|
+
} else if (errorMessage.includes('render') || errorMessage.includes('chart')) {
|
|
140
|
+
code = 'CHART_RENDER_FAILED';
|
|
141
|
+
} else {
|
|
142
|
+
code = 'INTERNAL_ERROR';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return mcpError({
|
|
146
|
+
code,
|
|
147
|
+
message: errorMessage,
|
|
148
|
+
retryable: code === 'EPHEMERIS_COMPUTE_FAILED' || code === 'FILE_WRITE_FAILED',
|
|
149
|
+
details: { tool: name },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
export async function main() {
|
|
155
|
+
logger.info('Initializing Swiss Ephemeris');
|
|
156
|
+
await astroService.init();
|
|
157
|
+
logger.info('Ephemeris initialized');
|
|
158
|
+
|
|
159
|
+
const transport = new StdioServerTransport();
|
|
160
|
+
await server.connect(transport);
|
|
161
|
+
logger.info('Astro MCP server running on stdio');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Only run if this is the main module
|
|
165
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
166
|
+
main().catch((error) => {
|
|
167
|
+
console.error('Server error:', error);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
});
|
|
170
|
+
}
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
// Set up browser globals for astrochart library BEFORE any imports
|
|
6
|
+
import { JSDOM } from 'jsdom';
|
|
7
|
+
|
|
8
|
+
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
|
9
|
+
(globalThis as any).window = dom.window;
|
|
10
|
+
(globalThis as any).document = dom.window.document;
|
|
11
|
+
(globalThis as any).self = globalThis; // Critical: astrochart checks for 'self' - see https://github.com/AstroDraw/AstroChart/issues/85
|
|
12
|
+
(globalThis as any).SVGElement = dom.window.SVGElement;
|
|
13
|
+
|
|
14
|
+
// Polyfill fetch for file:// URLs used by chart tooling in Node.
|
|
15
|
+
const originalFetch = globalThis.fetch;
|
|
16
|
+
(globalThis as any).fetch = async (url: string | URL, ...args: any[]) => {
|
|
17
|
+
const urlStr = url.toString();
|
|
18
|
+
if (urlStr.startsWith('file://')) {
|
|
19
|
+
const filePath = fileURLToPath(urlStr);
|
|
20
|
+
const buffer = await readFile(filePath);
|
|
21
|
+
return {
|
|
22
|
+
ok: true,
|
|
23
|
+
arrayBuffer: async () =>
|
|
24
|
+
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return originalFetch(url, ...args);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Use dynamic import() so the above globals are set BEFORE index.js (and charts.js) load
|
|
31
|
+
import('./index.js').then(({ main }) => {
|
|
32
|
+
main().catch((error) => {
|
|
33
|
+
console.error('[ERROR] Failed to start server:', error);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type ErrorCategoryType, LogLevel, type LogLevelType } from './constants.js';
|
|
2
|
+
|
|
3
|
+
export interface LogEntry {
|
|
4
|
+
timestamp: string;
|
|
5
|
+
level: LogLevelType;
|
|
6
|
+
message: string;
|
|
7
|
+
context?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ErrorLogEntry extends LogEntry {
|
|
11
|
+
level: typeof LogLevel.ERROR;
|
|
12
|
+
category: ErrorCategoryType;
|
|
13
|
+
error?: Error;
|
|
14
|
+
stack?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class Logger {
|
|
18
|
+
private shouldLog(level: LogLevelType): boolean {
|
|
19
|
+
const minLevel = process.env.LOG_LEVEL || LogLevel.INFO;
|
|
20
|
+
const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR];
|
|
21
|
+
return levels.indexOf(level) >= levels.indexOf(minLevel as LogLevelType);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private formatEntry(entry: LogEntry | ErrorLogEntry): string {
|
|
25
|
+
const base = `[${entry.timestamp}] ${entry.level}: ${entry.message}`;
|
|
26
|
+
|
|
27
|
+
if ('category' in entry) {
|
|
28
|
+
return `${base} (${entry.category})${entry.context ? ` ${JSON.stringify(entry.context)}` : ''}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return `${base}${entry.context ? ` ${JSON.stringify(entry.context)}` : ''}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private log(entry: LogEntry | ErrorLogEntry): void {
|
|
35
|
+
if (!this.shouldLog(entry.level)) return;
|
|
36
|
+
|
|
37
|
+
const output = this.formatEntry(entry);
|
|
38
|
+
|
|
39
|
+
// MCP servers use stderr for logging (stdout is for protocol)
|
|
40
|
+
console.error(output);
|
|
41
|
+
|
|
42
|
+
if ('error' in entry && entry.error) {
|
|
43
|
+
console.error(entry.error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
debug(message: string, context?: Record<string, unknown>): void {
|
|
48
|
+
this.log({
|
|
49
|
+
timestamp: new Date().toISOString(),
|
|
50
|
+
level: LogLevel.DEBUG,
|
|
51
|
+
message,
|
|
52
|
+
context,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
info(message: string, context?: Record<string, unknown>): void {
|
|
57
|
+
this.log({
|
|
58
|
+
timestamp: new Date().toISOString(),
|
|
59
|
+
level: LogLevel.INFO,
|
|
60
|
+
message,
|
|
61
|
+
context,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
warn(message: string, context?: Record<string, unknown>): void {
|
|
66
|
+
this.log({
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
level: LogLevel.WARN,
|
|
69
|
+
message,
|
|
70
|
+
context,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
error(
|
|
75
|
+
message: string,
|
|
76
|
+
category: ErrorCategoryType,
|
|
77
|
+
error?: Error,
|
|
78
|
+
context?: Record<string, unknown>
|
|
79
|
+
): void {
|
|
80
|
+
this.log({
|
|
81
|
+
timestamp: new Date().toISOString(),
|
|
82
|
+
level: LogLevel.ERROR,
|
|
83
|
+
category,
|
|
84
|
+
message,
|
|
85
|
+
error,
|
|
86
|
+
stack: error?.stack,
|
|
87
|
+
context,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Special handler for Swiss Ephemeris warnings (not actual errors)
|
|
92
|
+
ephemerisWarning(warning: string): void {
|
|
93
|
+
// Only log Moshier fallback at debug level since it's expected
|
|
94
|
+
if (warning.includes('using Moshier')) {
|
|
95
|
+
this.debug('Using Moshier ephemeris (high-precision data files not found)', {
|
|
96
|
+
warning,
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
this.warn('Swiss Ephemeris warning', { warning });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const logger = new Logger();
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { constants as fsConstants } from 'node:fs';
|
|
2
|
+
import { access, readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import type { SetNatalChartInput } from './astro-service.js';
|
|
6
|
+
|
|
7
|
+
type HouseSystem = 'P' | 'W' | 'K' | 'E';
|
|
8
|
+
type BirthTimeDisambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
|
|
9
|
+
|
|
10
|
+
export interface AstroProfile {
|
|
11
|
+
name: string;
|
|
12
|
+
year: number;
|
|
13
|
+
month: number;
|
|
14
|
+
day: number;
|
|
15
|
+
hour: number;
|
|
16
|
+
minute: number;
|
|
17
|
+
latitude: number;
|
|
18
|
+
longitude: number;
|
|
19
|
+
timezone: string;
|
|
20
|
+
house_system?: HouseSystem;
|
|
21
|
+
birth_time_disambiguation?: BirthTimeDisambiguation;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AstroProfileFile {
|
|
25
|
+
version: 1;
|
|
26
|
+
defaultProfile?: string;
|
|
27
|
+
profiles: Record<string, AstroProfile>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ProfileErrorCode =
|
|
31
|
+
| 'PROFILE_FILE_NOT_FOUND'
|
|
32
|
+
| 'INVALID_PROFILE_FILE'
|
|
33
|
+
| 'PROFILE_NOT_FOUND'
|
|
34
|
+
| 'DEFAULT_PROFILE_NOT_FOUND'
|
|
35
|
+
| 'PROFILE_VALIDATION_FAILED';
|
|
36
|
+
|
|
37
|
+
export class ProfileStoreError extends Error {
|
|
38
|
+
readonly code: ProfileErrorCode;
|
|
39
|
+
|
|
40
|
+
constructor(code: ProfileErrorCode, message: string) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.name = 'ProfileStoreError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ResolveProfileOptions {
|
|
48
|
+
profileName?: string;
|
|
49
|
+
profileFile?: string;
|
|
50
|
+
env?: NodeJS.ProcessEnv;
|
|
51
|
+
cwd?: string;
|
|
52
|
+
homeDir?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ResolvedProfileSelection {
|
|
56
|
+
filePath: string;
|
|
57
|
+
file: AstroProfileFile;
|
|
58
|
+
profileName: string;
|
|
59
|
+
profile: AstroProfile;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function exists(filePath: string): Promise<boolean> {
|
|
63
|
+
try {
|
|
64
|
+
await access(filePath, fsConstants.R_OK);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
72
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function requireString(value: unknown, label: string, profileName: string): string {
|
|
76
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
77
|
+
throw new ProfileStoreError(
|
|
78
|
+
'PROFILE_VALIDATION_FAILED',
|
|
79
|
+
`Profile "${profileName}" is invalid: ${label} is missing`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function requireNumber(value: unknown, label: string, profileName: string): number {
|
|
86
|
+
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
87
|
+
throw new ProfileStoreError(
|
|
88
|
+
'PROFILE_VALIDATION_FAILED',
|
|
89
|
+
`Profile "${profileName}" is invalid: ${label} is missing`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function requireEnum<T extends string>(
|
|
96
|
+
value: unknown,
|
|
97
|
+
label: string,
|
|
98
|
+
allowed: readonly T[],
|
|
99
|
+
profileName: string
|
|
100
|
+
): T {
|
|
101
|
+
if (typeof value !== 'string' || !allowed.includes(value as T)) {
|
|
102
|
+
throw new ProfileStoreError(
|
|
103
|
+
'PROFILE_VALIDATION_FAILED',
|
|
104
|
+
`Profile "${profileName}" is invalid: ${label} must be one of ${allowed.join(', ')}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return value as T;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeProfile(profileName: string, value: unknown): AstroProfile {
|
|
111
|
+
if (!isRecord(value)) {
|
|
112
|
+
throw new ProfileStoreError(
|
|
113
|
+
'PROFILE_VALIDATION_FAILED',
|
|
114
|
+
`Profile "${profileName}" is invalid: profile must be an object`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const houseSystem = value.house_system;
|
|
119
|
+
const disambiguation = value.birth_time_disambiguation;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
name: requireString(value.name, 'name', profileName),
|
|
123
|
+
year: requireNumber(value.year, 'year', profileName),
|
|
124
|
+
month: requireNumber(value.month, 'month', profileName),
|
|
125
|
+
day: requireNumber(value.day, 'day', profileName),
|
|
126
|
+
hour: requireNumber(value.hour, 'hour', profileName),
|
|
127
|
+
minute: requireNumber(value.minute, 'minute', profileName),
|
|
128
|
+
latitude: requireNumber(value.latitude, 'latitude', profileName),
|
|
129
|
+
longitude: requireNumber(value.longitude, 'longitude', profileName),
|
|
130
|
+
timezone: requireString(value.timezone, 'timezone', profileName),
|
|
131
|
+
house_system:
|
|
132
|
+
houseSystem === undefined
|
|
133
|
+
? undefined
|
|
134
|
+
: (requireEnum(
|
|
135
|
+
houseSystem,
|
|
136
|
+
'house_system',
|
|
137
|
+
['P', 'W', 'K', 'E'],
|
|
138
|
+
profileName
|
|
139
|
+
) as HouseSystem),
|
|
140
|
+
birth_time_disambiguation:
|
|
141
|
+
disambiguation === undefined
|
|
142
|
+
? undefined
|
|
143
|
+
: (requireEnum(
|
|
144
|
+
disambiguation,
|
|
145
|
+
'birth_time_disambiguation',
|
|
146
|
+
['compatible', 'earlier', 'later', 'reject'],
|
|
147
|
+
profileName
|
|
148
|
+
) as BirthTimeDisambiguation),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function resolveProfileFilePath(
|
|
153
|
+
options: ResolveProfileOptions,
|
|
154
|
+
required: boolean
|
|
155
|
+
): Promise<string | null> {
|
|
156
|
+
const env = options.env ?? process.env;
|
|
157
|
+
const cwd = options.cwd ?? process.cwd();
|
|
158
|
+
const homeDir = options.homeDir ?? homedir();
|
|
159
|
+
|
|
160
|
+
const explicitPath = options.profileFile ?? env.ASTRO_PROFILE_FILE;
|
|
161
|
+
if (explicitPath) {
|
|
162
|
+
const resolved = path.resolve(cwd, explicitPath);
|
|
163
|
+
if (!(await exists(resolved))) {
|
|
164
|
+
throw new ProfileStoreError('PROFILE_FILE_NOT_FOUND', `Profile file not found: ${resolved}`);
|
|
165
|
+
}
|
|
166
|
+
return resolved;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const localPath = path.resolve(cwd, '.astro.json');
|
|
170
|
+
if (await exists(localPath)) {
|
|
171
|
+
return localPath;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const homePath = path.resolve(homeDir, '.astro.json');
|
|
175
|
+
if (await exists(homePath)) {
|
|
176
|
+
return homePath;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (required) {
|
|
180
|
+
throw new ProfileStoreError(
|
|
181
|
+
'PROFILE_FILE_NOT_FOUND',
|
|
182
|
+
`Profile file not found: searched ${localPath} and ${homePath}`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function loadAstroProfileFile(filePath: string): Promise<AstroProfileFile> {
|
|
189
|
+
let parsed: unknown;
|
|
190
|
+
try {
|
|
191
|
+
const raw = await readFile(filePath, 'utf8');
|
|
192
|
+
parsed = JSON.parse(raw);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
195
|
+
throw new ProfileStoreError(
|
|
196
|
+
'INVALID_PROFILE_FILE',
|
|
197
|
+
`Invalid profile file: ${filePath} (${message})`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!isRecord(parsed)) {
|
|
202
|
+
throw new ProfileStoreError(
|
|
203
|
+
'INVALID_PROFILE_FILE',
|
|
204
|
+
`Invalid profile file: ${filePath} (root must be an object)`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (parsed.version !== 1) {
|
|
208
|
+
throw new ProfileStoreError(
|
|
209
|
+
'INVALID_PROFILE_FILE',
|
|
210
|
+
`Invalid profile file: ${filePath} (version must be 1)`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (!isRecord(parsed.profiles)) {
|
|
214
|
+
throw new ProfileStoreError(
|
|
215
|
+
'INVALID_PROFILE_FILE',
|
|
216
|
+
`Invalid profile file: ${filePath} (profiles must be an object)`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (parsed.defaultProfile !== undefined && typeof parsed.defaultProfile !== 'string') {
|
|
220
|
+
throw new ProfileStoreError(
|
|
221
|
+
'INVALID_PROFILE_FILE',
|
|
222
|
+
`Invalid profile file: ${filePath} (defaultProfile must be a string)`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const normalizedProfiles: Record<string, AstroProfile> = {};
|
|
227
|
+
for (const [profileName, profileValue] of Object.entries(parsed.profiles)) {
|
|
228
|
+
normalizedProfiles[profileName] = normalizeProfile(profileName, profileValue);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
version: 1,
|
|
233
|
+
defaultProfile: parsed.defaultProfile as string | undefined,
|
|
234
|
+
profiles: normalizedProfiles,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function resolveProfileSelection(
|
|
239
|
+
options: ResolveProfileOptions
|
|
240
|
+
): Promise<ResolvedProfileSelection | null> {
|
|
241
|
+
const env = options.env ?? process.env;
|
|
242
|
+
const filePath = await resolveProfileFilePath(options, false);
|
|
243
|
+
if (!filePath) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const file = await loadAstroProfileFile(filePath);
|
|
248
|
+
const profileName = options.profileName ?? env.ASTRO_PROFILE ?? file.defaultProfile;
|
|
249
|
+
if (!profileName) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const profile = file.profiles[profileName];
|
|
254
|
+
if (!profile) {
|
|
255
|
+
if (!options.profileName && !env.ASTRO_PROFILE && file.defaultProfile === profileName) {
|
|
256
|
+
throw new ProfileStoreError(
|
|
257
|
+
'DEFAULT_PROFILE_NOT_FOUND',
|
|
258
|
+
`defaultProfile is set to "${profileName}", but no such profile exists`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
throw new ProfileStoreError(
|
|
262
|
+
'PROFILE_NOT_FOUND',
|
|
263
|
+
`Profile "${profileName}" not found in ${filePath}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
filePath,
|
|
269
|
+
file,
|
|
270
|
+
profileName,
|
|
271
|
+
profile,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function loadResolvedProfileFile(
|
|
276
|
+
options: ResolveProfileOptions
|
|
277
|
+
): Promise<{ filePath: string; file: AstroProfileFile }> {
|
|
278
|
+
const filePath = await resolveProfileFilePath(options, true);
|
|
279
|
+
const file = await loadAstroProfileFile(filePath as string);
|
|
280
|
+
return { filePath: filePath as string, file };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function toNatalInput(profile: AstroProfile): SetNatalChartInput {
|
|
284
|
+
return { ...profile };
|
|
285
|
+
}
|