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/dist/index.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
15
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
16
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
17
|
+
import { AstroService } from './astro-service.js';
|
|
18
|
+
import { logger } from './logger.js';
|
|
19
|
+
import { getToolSpec, MCP_TOOL_SPECS } from './tool-registry.js';
|
|
20
|
+
import { mcpError, mcpResult, missingNatalChart } from './tool-result.js';
|
|
21
|
+
const server = new Server({
|
|
22
|
+
name: 'e2a-mcp',
|
|
23
|
+
version: '1.0.0',
|
|
24
|
+
}, {
|
|
25
|
+
capabilities: {
|
|
26
|
+
tools: {},
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
/**
|
|
30
|
+
* In-memory natal chart — the server's sole piece of mutable state.
|
|
31
|
+
*
|
|
32
|
+
* Lifecycle:
|
|
33
|
+
* - Starts as `null` when the process launches.
|
|
34
|
+
* - Set by `set_natal_chart`; overwritten on each call.
|
|
35
|
+
* - Persists for the lifetime of this stdio process (one per MCP client).
|
|
36
|
+
* - Tools that require it (`get_transits`, `get_houses`, `get_rise_set_times`,
|
|
37
|
+
* `generate_natal_chart`, `generate_transit_chart`) return a structured
|
|
38
|
+
* MISSING_NATAL_CHART error if it is null.
|
|
39
|
+
* - Use `get_server_status` to inspect whether a chart is loaded.
|
|
40
|
+
*
|
|
41
|
+
* Thread safety: Each MCP client connection spawns a separate Node.js process
|
|
42
|
+
* via stdio transport, so this variable is isolated per client.
|
|
43
|
+
* No synchronization needed as requests are serialized within a single process.
|
|
44
|
+
*/
|
|
45
|
+
let natalChart = null;
|
|
46
|
+
// Calculator instances (initialized on demand)
|
|
47
|
+
const astroService = new AstroService();
|
|
48
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
49
|
+
return {
|
|
50
|
+
tools: MCP_TOOL_SPECS.map((spec) => ({
|
|
51
|
+
name: spec.name,
|
|
52
|
+
description: spec.description,
|
|
53
|
+
inputSchema: spec.inputSchema,
|
|
54
|
+
})),
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* Handle MCP tool requests
|
|
59
|
+
*
|
|
60
|
+
* @param request - The MCP tool request
|
|
61
|
+
* @returns Tool response with data or error
|
|
62
|
+
* @throws Error for unhandled tools
|
|
63
|
+
*
|
|
64
|
+
* @remarks
|
|
65
|
+
* Routes requests to appropriate handlers. Initializes ephemeris on first use.
|
|
66
|
+
* All handlers return structured responses suitable for MCP clients.
|
|
67
|
+
*/
|
|
68
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
69
|
+
const { name, arguments: args = {} } = request.params;
|
|
70
|
+
try {
|
|
71
|
+
const spec = getToolSpec(name);
|
|
72
|
+
if (!spec) {
|
|
73
|
+
return mcpError({
|
|
74
|
+
code: 'INVALID_INPUT',
|
|
75
|
+
message: `Unknown tool: ${name}`,
|
|
76
|
+
retryable: false,
|
|
77
|
+
suggestedFix: 'Check the tool name against the list returned by ListTools.',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (spec.requiresNatalChart && !natalChart) {
|
|
81
|
+
return mcpError(missingNatalChart());
|
|
82
|
+
}
|
|
83
|
+
const result = await spec.execute({
|
|
84
|
+
service: astroService,
|
|
85
|
+
natalChart,
|
|
86
|
+
}, args);
|
|
87
|
+
if (result.kind === 'state') {
|
|
88
|
+
if (result.natalChart !== undefined) {
|
|
89
|
+
natalChart = result.natalChart;
|
|
90
|
+
}
|
|
91
|
+
return mcpResult(result.data, result.text);
|
|
92
|
+
}
|
|
93
|
+
return { content: result.content };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
97
|
+
// Map known error patterns to appropriate codes
|
|
98
|
+
let code;
|
|
99
|
+
if (errorMessage.includes('Invalid date format') ||
|
|
100
|
+
errorMessage.includes('Invalid calendar date') ||
|
|
101
|
+
errorMessage.includes('Invalid month') ||
|
|
102
|
+
errorMessage.includes('Invalid day') ||
|
|
103
|
+
errorMessage.includes('days_ahead') ||
|
|
104
|
+
errorMessage.includes('max_orb') ||
|
|
105
|
+
errorMessage.includes('missing julianDay')) {
|
|
106
|
+
code = 'INVALID_INPUT';
|
|
107
|
+
}
|
|
108
|
+
else if (errorMessage.includes('Invalid timezone') || errorMessage.includes('timezone')) {
|
|
109
|
+
code = 'INVALID_TIMEZONE';
|
|
110
|
+
}
|
|
111
|
+
else if (errorMessage.includes('Invalid house system')) {
|
|
112
|
+
code = 'INVALID_HOUSE_SYSTEM';
|
|
113
|
+
}
|
|
114
|
+
else if (errorMessage.includes('Ephemeris') || errorMessage.includes('ephemeris')) {
|
|
115
|
+
code = 'EPHEMERIS_COMPUTE_FAILED';
|
|
116
|
+
}
|
|
117
|
+
else if (errorMessage.includes('write') ||
|
|
118
|
+
errorMessage.includes('ENOENT') ||
|
|
119
|
+
errorMessage.includes('EACCES')) {
|
|
120
|
+
code = 'FILE_WRITE_FAILED';
|
|
121
|
+
}
|
|
122
|
+
else if (errorMessage.includes('render') || errorMessage.includes('chart')) {
|
|
123
|
+
code = 'CHART_RENDER_FAILED';
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
code = 'INTERNAL_ERROR';
|
|
127
|
+
}
|
|
128
|
+
return mcpError({
|
|
129
|
+
code,
|
|
130
|
+
message: errorMessage,
|
|
131
|
+
retryable: code === 'EPHEMERIS_COMPUTE_FAILED' || code === 'FILE_WRITE_FAILED',
|
|
132
|
+
details: { tool: name },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
export async function main() {
|
|
137
|
+
logger.info('Initializing Swiss Ephemeris');
|
|
138
|
+
await astroService.init();
|
|
139
|
+
logger.info('Ephemeris initialized');
|
|
140
|
+
const transport = new StdioServerTransport();
|
|
141
|
+
await server.connect(transport);
|
|
142
|
+
logger.info('Astro MCP server running on stdio');
|
|
143
|
+
}
|
|
144
|
+
// Only run if this is the main module
|
|
145
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
146
|
+
main().catch((error) => {
|
|
147
|
+
console.error('Server error:', error);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
|
150
|
+
}
|
package/dist/loader.d.ts
ADDED
package/dist/loader.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
// Set up browser globals for astrochart library BEFORE any imports
|
|
5
|
+
import { JSDOM } from 'jsdom';
|
|
6
|
+
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
|
7
|
+
globalThis.window = dom.window;
|
|
8
|
+
globalThis.document = dom.window.document;
|
|
9
|
+
globalThis.self = globalThis; // Critical: astrochart checks for 'self' - see https://github.com/AstroDraw/AstroChart/issues/85
|
|
10
|
+
globalThis.SVGElement = dom.window.SVGElement;
|
|
11
|
+
// Polyfill fetch for file:// URLs used by chart tooling in Node.
|
|
12
|
+
const originalFetch = globalThis.fetch;
|
|
13
|
+
globalThis.fetch = async (url, ...args) => {
|
|
14
|
+
const urlStr = url.toString();
|
|
15
|
+
if (urlStr.startsWith('file://')) {
|
|
16
|
+
const filePath = fileURLToPath(urlStr);
|
|
17
|
+
const buffer = await readFile(filePath);
|
|
18
|
+
return {
|
|
19
|
+
ok: true,
|
|
20
|
+
arrayBuffer: async () => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return originalFetch(url, ...args);
|
|
24
|
+
};
|
|
25
|
+
// Use dynamic import() so the above globals are set BEFORE index.js (and charts.js) load
|
|
26
|
+
import('./index.js').then(({ main }) => {
|
|
27
|
+
main().catch((error) => {
|
|
28
|
+
console.error('[ERROR] Failed to start server:', error);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ErrorCategoryType, LogLevel, type LogLevelType } from './constants.js';
|
|
2
|
+
export interface LogEntry {
|
|
3
|
+
timestamp: string;
|
|
4
|
+
level: LogLevelType;
|
|
5
|
+
message: string;
|
|
6
|
+
context?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface ErrorLogEntry extends LogEntry {
|
|
9
|
+
level: typeof LogLevel.ERROR;
|
|
10
|
+
category: ErrorCategoryType;
|
|
11
|
+
error?: Error;
|
|
12
|
+
stack?: string;
|
|
13
|
+
}
|
|
14
|
+
declare class Logger {
|
|
15
|
+
private shouldLog;
|
|
16
|
+
private formatEntry;
|
|
17
|
+
private log;
|
|
18
|
+
debug(message: string, context?: Record<string, unknown>): void;
|
|
19
|
+
info(message: string, context?: Record<string, unknown>): void;
|
|
20
|
+
warn(message: string, context?: Record<string, unknown>): void;
|
|
21
|
+
error(message: string, category: ErrorCategoryType, error?: Error, context?: Record<string, unknown>): void;
|
|
22
|
+
ephemerisWarning(warning: string): void;
|
|
23
|
+
}
|
|
24
|
+
export declare const logger: Logger;
|
|
25
|
+
export {};
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { LogLevel } from './constants.js';
|
|
2
|
+
class Logger {
|
|
3
|
+
shouldLog(level) {
|
|
4
|
+
const minLevel = process.env.LOG_LEVEL || LogLevel.INFO;
|
|
5
|
+
const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR];
|
|
6
|
+
return levels.indexOf(level) >= levels.indexOf(minLevel);
|
|
7
|
+
}
|
|
8
|
+
formatEntry(entry) {
|
|
9
|
+
const base = `[${entry.timestamp}] ${entry.level}: ${entry.message}`;
|
|
10
|
+
if ('category' in entry) {
|
|
11
|
+
return `${base} (${entry.category})${entry.context ? ` ${JSON.stringify(entry.context)}` : ''}`;
|
|
12
|
+
}
|
|
13
|
+
return `${base}${entry.context ? ` ${JSON.stringify(entry.context)}` : ''}`;
|
|
14
|
+
}
|
|
15
|
+
log(entry) {
|
|
16
|
+
if (!this.shouldLog(entry.level))
|
|
17
|
+
return;
|
|
18
|
+
const output = this.formatEntry(entry);
|
|
19
|
+
// MCP servers use stderr for logging (stdout is for protocol)
|
|
20
|
+
console.error(output);
|
|
21
|
+
if ('error' in entry && entry.error) {
|
|
22
|
+
console.error(entry.error);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
debug(message, context) {
|
|
26
|
+
this.log({
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
level: LogLevel.DEBUG,
|
|
29
|
+
message,
|
|
30
|
+
context,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
info(message, context) {
|
|
34
|
+
this.log({
|
|
35
|
+
timestamp: new Date().toISOString(),
|
|
36
|
+
level: LogLevel.INFO,
|
|
37
|
+
message,
|
|
38
|
+
context,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
warn(message, context) {
|
|
42
|
+
this.log({
|
|
43
|
+
timestamp: new Date().toISOString(),
|
|
44
|
+
level: LogLevel.WARN,
|
|
45
|
+
message,
|
|
46
|
+
context,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
error(message, category, error, context) {
|
|
50
|
+
this.log({
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
level: LogLevel.ERROR,
|
|
53
|
+
category,
|
|
54
|
+
message,
|
|
55
|
+
error,
|
|
56
|
+
stack: error?.stack,
|
|
57
|
+
context,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// Special handler for Swiss Ephemeris warnings (not actual errors)
|
|
61
|
+
ephemerisWarning(warning) {
|
|
62
|
+
// Only log Moshier fallback at debug level since it's expected
|
|
63
|
+
if (warning.includes('using Moshier')) {
|
|
64
|
+
this.debug('Using Moshier ephemeris (high-precision data files not found)', {
|
|
65
|
+
warning,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
this.warn('Swiss Ephemeris warning', { warning });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export const logger = new Logger();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SetNatalChartInput } from './astro-service.js';
|
|
2
|
+
type HouseSystem = 'P' | 'W' | 'K' | 'E';
|
|
3
|
+
type BirthTimeDisambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
|
|
4
|
+
export interface AstroProfile {
|
|
5
|
+
name: string;
|
|
6
|
+
year: number;
|
|
7
|
+
month: number;
|
|
8
|
+
day: number;
|
|
9
|
+
hour: number;
|
|
10
|
+
minute: number;
|
|
11
|
+
latitude: number;
|
|
12
|
+
longitude: number;
|
|
13
|
+
timezone: string;
|
|
14
|
+
house_system?: HouseSystem;
|
|
15
|
+
birth_time_disambiguation?: BirthTimeDisambiguation;
|
|
16
|
+
}
|
|
17
|
+
export interface AstroProfileFile {
|
|
18
|
+
version: 1;
|
|
19
|
+
defaultProfile?: string;
|
|
20
|
+
profiles: Record<string, AstroProfile>;
|
|
21
|
+
}
|
|
22
|
+
export type ProfileErrorCode = 'PROFILE_FILE_NOT_FOUND' | 'INVALID_PROFILE_FILE' | 'PROFILE_NOT_FOUND' | 'DEFAULT_PROFILE_NOT_FOUND' | 'PROFILE_VALIDATION_FAILED';
|
|
23
|
+
export declare class ProfileStoreError extends Error {
|
|
24
|
+
readonly code: ProfileErrorCode;
|
|
25
|
+
constructor(code: ProfileErrorCode, message: string);
|
|
26
|
+
}
|
|
27
|
+
export interface ResolveProfileOptions {
|
|
28
|
+
profileName?: string;
|
|
29
|
+
profileFile?: string;
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
cwd?: string;
|
|
32
|
+
homeDir?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ResolvedProfileSelection {
|
|
35
|
+
filePath: string;
|
|
36
|
+
file: AstroProfileFile;
|
|
37
|
+
profileName: string;
|
|
38
|
+
profile: AstroProfile;
|
|
39
|
+
}
|
|
40
|
+
export declare function resolveProfileFilePath(options: ResolveProfileOptions, required: boolean): Promise<string | null>;
|
|
41
|
+
export declare function loadAstroProfileFile(filePath: string): Promise<AstroProfileFile>;
|
|
42
|
+
export declare function resolveProfileSelection(options: ResolveProfileOptions): Promise<ResolvedProfileSelection | null>;
|
|
43
|
+
export declare function loadResolvedProfileFile(options: ResolveProfileOptions): Promise<{
|
|
44
|
+
filePath: string;
|
|
45
|
+
file: AstroProfileFile;
|
|
46
|
+
}>;
|
|
47
|
+
export declare function toNatalInput(profile: AstroProfile): SetNatalChartInput;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
export class ProfileStoreError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = 'ProfileStoreError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
async function exists(filePath) {
|
|
14
|
+
try {
|
|
15
|
+
await access(filePath, fsConstants.R_OK);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function requireString(value, label, profileName) {
|
|
26
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
27
|
+
throw new ProfileStoreError('PROFILE_VALIDATION_FAILED', `Profile "${profileName}" is invalid: ${label} is missing`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function requireNumber(value, label, profileName) {
|
|
32
|
+
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
33
|
+
throw new ProfileStoreError('PROFILE_VALIDATION_FAILED', `Profile "${profileName}" is invalid: ${label} is missing`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
function requireEnum(value, label, allowed, profileName) {
|
|
38
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
39
|
+
throw new ProfileStoreError('PROFILE_VALIDATION_FAILED', `Profile "${profileName}" is invalid: ${label} must be one of ${allowed.join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function normalizeProfile(profileName, value) {
|
|
44
|
+
if (!isRecord(value)) {
|
|
45
|
+
throw new ProfileStoreError('PROFILE_VALIDATION_FAILED', `Profile "${profileName}" is invalid: profile must be an object`);
|
|
46
|
+
}
|
|
47
|
+
const houseSystem = value.house_system;
|
|
48
|
+
const disambiguation = value.birth_time_disambiguation;
|
|
49
|
+
return {
|
|
50
|
+
name: requireString(value.name, 'name', profileName),
|
|
51
|
+
year: requireNumber(value.year, 'year', profileName),
|
|
52
|
+
month: requireNumber(value.month, 'month', profileName),
|
|
53
|
+
day: requireNumber(value.day, 'day', profileName),
|
|
54
|
+
hour: requireNumber(value.hour, 'hour', profileName),
|
|
55
|
+
minute: requireNumber(value.minute, 'minute', profileName),
|
|
56
|
+
latitude: requireNumber(value.latitude, 'latitude', profileName),
|
|
57
|
+
longitude: requireNumber(value.longitude, 'longitude', profileName),
|
|
58
|
+
timezone: requireString(value.timezone, 'timezone', profileName),
|
|
59
|
+
house_system: houseSystem === undefined
|
|
60
|
+
? undefined
|
|
61
|
+
: requireEnum(houseSystem, 'house_system', ['P', 'W', 'K', 'E'], profileName),
|
|
62
|
+
birth_time_disambiguation: disambiguation === undefined
|
|
63
|
+
? undefined
|
|
64
|
+
: requireEnum(disambiguation, 'birth_time_disambiguation', ['compatible', 'earlier', 'later', 'reject'], profileName),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export async function resolveProfileFilePath(options, required) {
|
|
68
|
+
const env = options.env ?? process.env;
|
|
69
|
+
const cwd = options.cwd ?? process.cwd();
|
|
70
|
+
const homeDir = options.homeDir ?? homedir();
|
|
71
|
+
const explicitPath = options.profileFile ?? env.ASTRO_PROFILE_FILE;
|
|
72
|
+
if (explicitPath) {
|
|
73
|
+
const resolved = path.resolve(cwd, explicitPath);
|
|
74
|
+
if (!(await exists(resolved))) {
|
|
75
|
+
throw new ProfileStoreError('PROFILE_FILE_NOT_FOUND', `Profile file not found: ${resolved}`);
|
|
76
|
+
}
|
|
77
|
+
return resolved;
|
|
78
|
+
}
|
|
79
|
+
const localPath = path.resolve(cwd, '.astro.json');
|
|
80
|
+
if (await exists(localPath)) {
|
|
81
|
+
return localPath;
|
|
82
|
+
}
|
|
83
|
+
const homePath = path.resolve(homeDir, '.astro.json');
|
|
84
|
+
if (await exists(homePath)) {
|
|
85
|
+
return homePath;
|
|
86
|
+
}
|
|
87
|
+
if (required) {
|
|
88
|
+
throw new ProfileStoreError('PROFILE_FILE_NOT_FOUND', `Profile file not found: searched ${localPath} and ${homePath}`);
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
export async function loadAstroProfileFile(filePath) {
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
const raw = await readFile(filePath, 'utf8');
|
|
96
|
+
parsed = JSON.parse(raw);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
+
throw new ProfileStoreError('INVALID_PROFILE_FILE', `Invalid profile file: ${filePath} (${message})`);
|
|
101
|
+
}
|
|
102
|
+
if (!isRecord(parsed)) {
|
|
103
|
+
throw new ProfileStoreError('INVALID_PROFILE_FILE', `Invalid profile file: ${filePath} (root must be an object)`);
|
|
104
|
+
}
|
|
105
|
+
if (parsed.version !== 1) {
|
|
106
|
+
throw new ProfileStoreError('INVALID_PROFILE_FILE', `Invalid profile file: ${filePath} (version must be 1)`);
|
|
107
|
+
}
|
|
108
|
+
if (!isRecord(parsed.profiles)) {
|
|
109
|
+
throw new ProfileStoreError('INVALID_PROFILE_FILE', `Invalid profile file: ${filePath} (profiles must be an object)`);
|
|
110
|
+
}
|
|
111
|
+
if (parsed.defaultProfile !== undefined && typeof parsed.defaultProfile !== 'string') {
|
|
112
|
+
throw new ProfileStoreError('INVALID_PROFILE_FILE', `Invalid profile file: ${filePath} (defaultProfile must be a string)`);
|
|
113
|
+
}
|
|
114
|
+
const normalizedProfiles = {};
|
|
115
|
+
for (const [profileName, profileValue] of Object.entries(parsed.profiles)) {
|
|
116
|
+
normalizedProfiles[profileName] = normalizeProfile(profileName, profileValue);
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
version: 1,
|
|
120
|
+
defaultProfile: parsed.defaultProfile,
|
|
121
|
+
profiles: normalizedProfiles,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export async function resolveProfileSelection(options) {
|
|
125
|
+
const env = options.env ?? process.env;
|
|
126
|
+
const filePath = await resolveProfileFilePath(options, false);
|
|
127
|
+
if (!filePath) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const file = await loadAstroProfileFile(filePath);
|
|
131
|
+
const profileName = options.profileName ?? env.ASTRO_PROFILE ?? file.defaultProfile;
|
|
132
|
+
if (!profileName) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const profile = file.profiles[profileName];
|
|
136
|
+
if (!profile) {
|
|
137
|
+
if (!options.profileName && !env.ASTRO_PROFILE && file.defaultProfile === profileName) {
|
|
138
|
+
throw new ProfileStoreError('DEFAULT_PROFILE_NOT_FOUND', `defaultProfile is set to "${profileName}", but no such profile exists`);
|
|
139
|
+
}
|
|
140
|
+
throw new ProfileStoreError('PROFILE_NOT_FOUND', `Profile "${profileName}" not found in ${filePath}`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
filePath,
|
|
144
|
+
file,
|
|
145
|
+
profileName,
|
|
146
|
+
profile,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export async function loadResolvedProfileFile(options) {
|
|
150
|
+
const filePath = await resolveProfileFilePath(options, true);
|
|
151
|
+
const file = await loadAstroProfileFile(filePath);
|
|
152
|
+
return { filePath: filePath, file };
|
|
153
|
+
}
|
|
154
|
+
export function toNatalInput(profile) {
|
|
155
|
+
return { ...profile };
|
|
156
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { EphemerisCalculator } from './ephemeris.js';
|
|
2
|
+
import { type RiseSetTime } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Calculator for rise, set, and meridian transit times
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* Calculates when celestial bodies rise above and set below the horizon,
|
|
8
|
+
* plus upper and lower meridian transits. Handles circumpolar objects
|
|
9
|
+
* and atmospheric refraction corrections.
|
|
10
|
+
*/
|
|
11
|
+
export declare class RiseSetCalculator {
|
|
12
|
+
/** Ephemeris calculator instance */
|
|
13
|
+
private ephem;
|
|
14
|
+
/**
|
|
15
|
+
* Create a new rise/set calculator
|
|
16
|
+
*
|
|
17
|
+
* @param ephem - Initialized ephemeris calculator
|
|
18
|
+
* @throws Error if ephemeris is not initialized
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* The ephemeris calculator must be initialized before passing
|
|
22
|
+
* to the RiseSetCalculator constructor.
|
|
23
|
+
*/
|
|
24
|
+
constructor(ephem: EphemerisCalculator);
|
|
25
|
+
/**
|
|
26
|
+
* Calculate rise, set, and meridian transit times for a celestial body
|
|
27
|
+
*
|
|
28
|
+
* Uses standard astronomical definitions:
|
|
29
|
+
* - Rise/Set: Upper limb of disc with atmospheric refraction considered
|
|
30
|
+
* - Atmospheric pressure: Estimated from altitude (sea level if altitude=0)
|
|
31
|
+
* - Temperature: 0°C (default assumption)
|
|
32
|
+
* - Upper meridian: Highest point in sky (culmination)
|
|
33
|
+
* - Lower meridian: Lowest point in sky (anti-culmination)
|
|
34
|
+
*
|
|
35
|
+
* Swiss Ephemeris return codes:
|
|
36
|
+
* - 0 or positive: Event found successfully
|
|
37
|
+
* - -1: Calculation error (hard failure)
|
|
38
|
+
* - -2: No event exists (circumpolar object)
|
|
39
|
+
*
|
|
40
|
+
* @param julianDay - Julian Day to start search from (typically midnight of target date)
|
|
41
|
+
* @param planetId - Swiss Ephemeris planet ID
|
|
42
|
+
* @param latitude - Observer latitude in degrees (-90 to 90)
|
|
43
|
+
* @param longitude - Observer longitude in degrees
|
|
44
|
+
* @param altitude - Observer altitude in meters (default: 0 = sea level)
|
|
45
|
+
* @returns Rise/set/transit times, or undefined fields if event doesn't occur
|
|
46
|
+
* @throws {Error} If ephemeris not initialized, invalid inputs, or hard calculation error
|
|
47
|
+
*/
|
|
48
|
+
calculateRiseSet(julianDay: number, planetId: number, latitude: number, longitude: number, altitude?: number): RiseSetTime;
|
|
49
|
+
/**
|
|
50
|
+
* Get rise/set times for all planets for a given date
|
|
51
|
+
*
|
|
52
|
+
* @param date - Date/time to use as search anchor (typically current instant or midnight of target date)
|
|
53
|
+
* @param latitude - Observer latitude in degrees (-90 to 90)
|
|
54
|
+
* @param longitude - Observer longitude in degrees
|
|
55
|
+
* @param altitude - Observer altitude in meters (default: 0)
|
|
56
|
+
* @returns Array of rise/set times for all planets
|
|
57
|
+
* @throws Error if ephemeris not initialized or invalid inputs
|
|
58
|
+
*
|
|
59
|
+
* @remarks
|
|
60
|
+
* Calculates for Sun through Pluto. Some fields may be undefined
|
|
61
|
+
* for circumpolar objects at extreme latitudes.
|
|
62
|
+
*
|
|
63
|
+
* Swiss Ephemeris searches for the NEXT event after the given instant,
|
|
64
|
+
* so to get events for a specific civil date, pass midnight of that date.
|
|
65
|
+
*/
|
|
66
|
+
getAllRiseSet(date: Date, latitude: number, longitude: number, altitude?: number): Promise<RiseSetTime[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Get Sun rise/set times for the current instant
|
|
69
|
+
*
|
|
70
|
+
* @param latitude - Observer latitude in degrees (-90 to 90)
|
|
71
|
+
* @param longitude - Observer longitude in degrees
|
|
72
|
+
* @param altitude - Observer altitude in meters (default: 0)
|
|
73
|
+
* @returns Rise/set times for the Sun
|
|
74
|
+
* @throws Error if ephemeris not initialized or invalid inputs
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* Searches for the next sunrise/sunset after the current instant.
|
|
78
|
+
* If called in the afternoon, sunrise will be tomorrow.
|
|
79
|
+
* For events on a specific civil date, use calculateRiseSet with midnight JD.
|
|
80
|
+
*/
|
|
81
|
+
getSunRiseSet(latitude: number, longitude: number, altitude?: number): Promise<RiseSetTime>;
|
|
82
|
+
}
|