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.
Files changed (138) hide show
  1. package/.env.example +13 -0
  2. package/.github/pull_request_template.md +16 -0
  3. package/.github/workflows/release.yml +35 -0
  4. package/.github/workflows/test.yml +32 -0
  5. package/AGENTS.md +99 -0
  6. package/LICENSE +18 -0
  7. package/NOTICE.md +45 -0
  8. package/README.md +301 -0
  9. package/SETUP.md +70 -0
  10. package/TESTING_SUMMARY.md +238 -0
  11. package/TEST_SUITE_STATUS.md +218 -0
  12. package/biome.json +48 -0
  13. package/dist/astro-service.d.ts +98 -0
  14. package/dist/astro-service.js +496 -0
  15. package/dist/chart-types.d.ts +52 -0
  16. package/dist/chart-types.js +51 -0
  17. package/dist/charts.d.ts +125 -0
  18. package/dist/charts.js +324 -0
  19. package/dist/cli.d.ts +7 -0
  20. package/dist/cli.js +472 -0
  21. package/dist/constants.d.ts +81 -0
  22. package/dist/constants.js +76 -0
  23. package/dist/eclipses.d.ts +85 -0
  24. package/dist/eclipses.js +184 -0
  25. package/dist/ephemeris.d.ts +120 -0
  26. package/dist/ephemeris.js +379 -0
  27. package/dist/formatter.d.ts +2 -0
  28. package/dist/formatter.js +22 -0
  29. package/dist/houses.d.ts +82 -0
  30. package/dist/houses.js +169 -0
  31. package/dist/index.d.ts +14 -0
  32. package/dist/index.js +150 -0
  33. package/dist/loader.d.ts +2 -0
  34. package/dist/loader.js +31 -0
  35. package/dist/logger.d.ts +25 -0
  36. package/dist/logger.js +73 -0
  37. package/dist/profile-store.d.ts +48 -0
  38. package/dist/profile-store.js +156 -0
  39. package/dist/riseset.d.ts +82 -0
  40. package/dist/riseset.js +185 -0
  41. package/dist/storage.d.ts +10 -0
  42. package/dist/storage.js +40 -0
  43. package/dist/time-utils.d.ts +68 -0
  44. package/dist/time-utils.js +136 -0
  45. package/dist/tool-registry.d.ts +35 -0
  46. package/dist/tool-registry.js +307 -0
  47. package/dist/tool-result.d.ts +175 -0
  48. package/dist/tool-result.js +188 -0
  49. package/dist/transits.d.ts +108 -0
  50. package/dist/transits.js +263 -0
  51. package/dist/types.d.ts +450 -0
  52. package/dist/types.js +161 -0
  53. package/example-usage.md +131 -0
  54. package/natal-chart.json +187 -0
  55. package/package.json +61 -0
  56. package/scripts/download-ephemeris.js +115 -0
  57. package/setup.sh +21 -0
  58. package/src/astro-service.ts +710 -0
  59. package/src/chart-types.ts +125 -0
  60. package/src/charts.ts +399 -0
  61. package/src/cli.ts +694 -0
  62. package/src/constants.ts +89 -0
  63. package/src/eclipses.ts +226 -0
  64. package/src/ephemeris.ts +437 -0
  65. package/src/formatter.ts +25 -0
  66. package/src/houses.ts +202 -0
  67. package/src/index.ts +170 -0
  68. package/src/loader.ts +36 -0
  69. package/src/logger.ts +104 -0
  70. package/src/profile-store.ts +285 -0
  71. package/src/riseset.ts +229 -0
  72. package/src/time-utils.ts +167 -0
  73. package/src/tool-registry.ts +357 -0
  74. package/src/tool-result.ts +283 -0
  75. package/src/transits.ts +352 -0
  76. package/src/types.ts +547 -0
  77. package/tests/README.md +173 -0
  78. package/tests/TESTING_STRATEGY.md +178 -0
  79. package/tests/fixtures/bowen-yang-chart.ts +69 -0
  80. package/tests/fixtures/calculate-expected.ts +81 -0
  81. package/tests/fixtures/expected-results.ts +117 -0
  82. package/tests/fixtures/generate-expected-simple.ts +94 -0
  83. package/tests/helpers/date-fixtures.ts +15 -0
  84. package/tests/helpers/ephem.ts +11 -0
  85. package/tests/helpers/temp.ts +9 -0
  86. package/tests/setup.ts +11 -0
  87. package/tests/unit/astro-service.test.ts +323 -0
  88. package/tests/unit/chart-types.test.ts +18 -0
  89. package/tests/unit/charts-errors.test.ts +42 -0
  90. package/tests/unit/charts.test.ts +157 -0
  91. package/tests/unit/cli-commands.test.ts +82 -0
  92. package/tests/unit/cli-profiles.test.ts +128 -0
  93. package/tests/unit/cli.test.ts +191 -0
  94. package/tests/unit/constants.test.ts +26 -0
  95. package/tests/unit/correctness-critical.test.ts +408 -0
  96. package/tests/unit/eclipses.test.ts +108 -0
  97. package/tests/unit/ephemeris.test.ts +213 -0
  98. package/tests/unit/error-handling.test.ts +116 -0
  99. package/tests/unit/formatter.test.ts +29 -0
  100. package/tests/unit/houses-errors.test.ts +27 -0
  101. package/tests/unit/houses-validation.test.ts +164 -0
  102. package/tests/unit/houses.test.ts +205 -0
  103. package/tests/unit/profile-store.test.ts +163 -0
  104. package/tests/unit/real-user-charts.test.ts +148 -0
  105. package/tests/unit/riseset.test.ts +106 -0
  106. package/tests/unit/solver-edges.test.ts +197 -0
  107. package/tests/unit/time-utils-temporal.test.ts +303 -0
  108. package/tests/unit/time-utils.test.ts +173 -0
  109. package/tests/unit/tool-registry.test.ts +222 -0
  110. package/tests/unit/tool-result.test.ts +45 -0
  111. package/tests/unit/transit-correctness.test.ts +78 -0
  112. package/tests/unit/transits.test.ts +238 -0
  113. package/tests/validation/README.md +32 -0
  114. package/tests/validation/adapters/astrolog.ts +306 -0
  115. package/tests/validation/adapters/internal.ts +184 -0
  116. package/tests/validation/compare/eclipses.ts +47 -0
  117. package/tests/validation/compare/houses.ts +76 -0
  118. package/tests/validation/compare/positions.ts +104 -0
  119. package/tests/validation/compare/riseSet.ts +48 -0
  120. package/tests/validation/compare/roots.ts +90 -0
  121. package/tests/validation/compare/transits.ts +69 -0
  122. package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
  123. package/tests/validation/fixtures/eclipses/core.ts +14 -0
  124. package/tests/validation/fixtures/houses/core.ts +47 -0
  125. package/tests/validation/fixtures/positions/core.ts +159 -0
  126. package/tests/validation/fixtures/rise-set/core.ts +20 -0
  127. package/tests/validation/fixtures/roots/core.ts +47 -0
  128. package/tests/validation/fixtures/transits/core.ts +61 -0
  129. package/tests/validation/fixtures/transits/dst.ts +21 -0
  130. package/tests/validation/oracle.spec.ts +129 -0
  131. package/tests/validation/utils/denseRootOracle.ts +269 -0
  132. package/tests/validation/utils/fixtureTypes.ts +146 -0
  133. package/tests/validation/utils/report.ts +60 -0
  134. package/tests/validation/utils/tolerances.ts +23 -0
  135. package/tests/validation/validation.spec.ts +836 -0
  136. package/tools/color-picker.html +388 -0
  137. package/tsconfig.json +17 -0
  138. 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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ });
@@ -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
+ }