Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/tempo/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.2.3] - 2026-06-20

### Added
- **Centralized Configuration**: Introduced `tempo.config.ts` and `tempo.config.js` discovery.
- **Async Bootstrap**: Added `Tempo.bootstrap()` for safe, asynchronous ES Module configuration resolution without breaking the synchronous `Tempo.init()` API.
- **CLI Scaffolding**: Added an `npx @magmacomputing/tempo scaffold:all` CLI tool to easily bootstrap `tempo.config.ts` and HTML sandboxes into projects.

### Changed
- **Zero-Dependency Resolution**: Implemented custom filesystem traversal to ensure robust config discovery without adding external dependencies (e.g., `cosmiconfig`).

## [3.2.2] - 2026-06-18

### Added
Expand Down
62 changes: 62 additions & 0 deletions packages/tempo/bin/tempo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env node

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// CLI Arguments
const args = process.argv.slice(2);
const command = args[0];

// The location of the templates relative to this bin script (bin/tempo.js -> template/)
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const templateDir = path.join(__dirname, '..', 'template');
const cwd = process.cwd();

// Helper to copy a file safely
function safelyCopy(sourceFile, targetFile, successMessage) {
const sourcePath = path.join(templateDir, sourceFile);
const targetPath = path.join(cwd, targetFile);

if (fs.existsSync(targetPath)) {
console.error(`\x1b[31m[Tempo Scaffold] Aborted.\x1b[0m File already exists at: ${targetPath}`);
console.error(`We refused to overwrite your existing file. If you want to scaffold a new one, please delete the existing file first.`);
process.exit(1);
}

if (!fs.existsSync(sourcePath)) {
console.error(`\x1b[31m[Tempo Scaffold] Error.\x1b[0m Could not find template file at: ${sourcePath}`);
process.exit(1);
}

try {
fs.copyFileSync(sourcePath, targetPath);
console.log(`\x1b[32m[Tempo Scaffold] Success!\x1b[0m ${successMessage}`);
} catch (err) {
console.error(`\x1b[31m[Tempo Scaffold] Failed to copy file:\x1b[0m ${err.message}`);
process.exit(1);
}
}

// Route commands
if (command === 'scaffold:config') {
safelyCopy('tempo.config.sample.ts', 'tempo.config.ts', 'Created tempo.config.ts');
} else if (command === 'scaffold:html') {
safelyCopy('index.sample.html', 'index.html', 'Created index.html boilerplate');
} else if (command === 'scaffold:all') {
safelyCopy('tempo.config.sample.ts', 'tempo.config.ts', 'Created tempo.config.ts');
safelyCopy('index.sample.html', 'index.html', 'Created index.html boilerplate');
Comment thread
magmacomputing marked this conversation as resolved.
} else {
console.log(`
\x1b[36m@magmacomputing/tempo\x1b[0m CLI

Available Commands:
scaffold:config Copies a sample tempo.config.ts into your current directory.
scaffold:html Copies a working HTML sandbox (index.html) into your current directory.
scaffold:all Copies both files.

Usage:
npx @magmacomputing/tempo <command>
`);
}
46 changes: 33 additions & 13 deletions packages/tempo/doc/tempo.config.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ Rather than scattering `Tempo.init()` or `Tempo.extend()` calls throughout your

This mirrors modern ecosystem standards (like `vite.config.ts` or `tailwind.config.js`) and ensures that plugins, timezones, and custom aliases are consistently applied before any domain logic executes.

::: info
**Target Environment**: This automatic configuration discovery pattern relies on Node.js file system capabilities and is designed for Server, Fullstack, or Bundled environments (like Vite or Webpack). If you are using Tempo via a `<script>` tag in a pure Browser environment, skip to [Explicit Initialization](#3-explicit-initialization-tempo-init) to configure Tempo synchronously!
Comment thread
magmacomputing marked this conversation as resolved.
Outdated
:::

```typescript
// tempo.config.ts
import { Tempo } from '@magmacomputing/tempo';
import { defineConfig } from '@magmacomputing/tempo';
import { CronModule } from '@magmacomputing/tempo-plugin-cron';
import { SLAModule } from '@magmacomputing/tempo-plugin-sla';

export const GlobalTempoConfig = {
export default defineConfig({
timeZone: 'Australia/Sydney', // Set your baseline timezone
license: 'eyJhbGciOiJIUzI1...', // JWT Commercial License for Premium Plugins
plugins: [CronModule, SLAModule], // Register enterprise plugins
Expand All @@ -36,25 +40,41 @@ export const GlobalTempoConfig = {
'market-close': '16:00'
}
}
}

// Bootstrap the global environment
Tempo.init(GlobalTempoConfig);
});
```

You can then import this file at the very top of your application's entry point (e.g., `main.ts` or `index.js`) to guarantee the configuration is locked in before any other files import `Tempo`.

::: tip
**Looking to configure Internationalization?**
Tempo offers deep integration with native `Intl` APIs for both parsing and formatting foreign languages out-of-the-box. See [The Role of Locale](./tempo.locale.md) for a general guide, and the [Internationalized Parsing](./tempo.parse.md#internationalized-parsing-locales) and [Format Modifiers & Localization](./tempo.cookbook.md#format-modifiers--localization) guides for configuration details.
:::
You can then bootstrap this environment at the very top of your application's entry point (e.g., `main.ts` or `index.js`) to guarantee the configuration is locked in before any other files run:

```typescript
// main.ts
import './tempo.config.ts';
import { Tempo } from '@magmacomputing/tempo';

// Automatically discovers and loads 'tempo.config.ts'
await Tempo.bootstrap();

import { App } from './app.js';
// ...
```
Comment thread
magmacomputing marked this conversation as resolved.
### Benefits vs. Drawbacks

Using `tempo.config.ts` is the modern standard, but it introduces specific architectural tradeoffs due to Node.js ES Module constraints.

#### 🌟 Benefits
- **TypeScript Autocomplete**: Using `defineConfig` provides instant IDE intellisense and type-safety for all configuration options.
- **Plugin Execution**: You can import and instantiate plugins directly inside the configuration file, keeping your application logic clean.
- **Dynamic Configuration**: Enables runtime logic (e.g., `debug: process.env.NODE_ENV !== 'production'`) that strict JSON cannot provide.

#### ⚠️ Drawbacks
- **Asynchronous Requirement**: Because `tempo.config.ts` is evaluated as an ES Module, the JavaScript engine *forces* it to be loaded asynchronously via dynamic `import()`. This means you **must** use `await Tempo.bootstrap()` instead of the synchronous `Tempo.init()`.

#### 🛑 Risks
- **Node.js Environment Only**: The `bootstrap()` automatic file discovery relies on Node.js (`fs`, `path`). If you are running strictly in a browser (e.g., via CDN without a bundler), automatic discovery will safely abort, and `bootstrap()` will simply act as a pass-through to `Tempo.init()`. In these environments, you must bundle your config or manually pass your options to `Tempo.init(options)`.
- **Floating Promises**: You must ensure you actually `await` the bootstrap call. If you forget the `await` keyword, your application will continue booting before Tempo finishes reading your config file, leading to race conditions where early instances use default settings.

::: tip
**Looking to configure Internationalization?**
Tempo offers deep integration with native `Intl` APIs for both parsing and formatting foreign languages out-of-the-box. See [The Role of Locale](./tempo.locale.md) for a general guide, and the [Internationalized Parsing](./tempo.parse.md#internationalized-parsing-locales) and [Format Modifiers & Localization](./tempo.cookbook.md#format-modifiers--localization) guides for configuration details.
:::

---

Expand Down
15 changes: 0 additions & 15 deletions packages/tempo/importmap.json

This file was deleted.

7 changes: 6 additions & 1 deletion packages/tempo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"engines": {
"node": ">=20.0.0"
},
"bin": {
"tempo": "./bin/tempo.js"
},
"description": "The Tempo core library",
"author": "Magma Computing Solutions",
"license": "MIT",
Expand Down Expand Up @@ -208,8 +211,10 @@
"docs:push": "bash ./bin/push-docs.sh"
},
"files": [
"bin/",
"dist/",
"img/"
"img/",
"template/"
],
"dependencies": {
"tslib": "^2.8.1"
Expand Down
8 changes: 8 additions & 0 deletions packages/tempo/src/config/defineConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Options } from '../tempo.type.js';

/**
* Identity function to provide TypeScript autocomplete for Tempo configurations.
*/
export function defineConfig(config: Options): Options {
return config;
}
86 changes: 86 additions & 0 deletions packages/tempo/src/config/resolveConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { isFunction } from '#library/assertion.library.js';
import type { Options } from '../tempo.type.js';

// Minimal declaration so TS doesn't complain in browser environments without @types/node
declare const process: any;

/**
* Automatically discovers and loads a tempo.config.* file by traversing upwards
* from the current working directory until a package.json is found.
*/
export async function resolveConfig(options?: { cwd?: string, configFile?: string }): Promise<Options | undefined> {
// Skip discovery if not in a Node/Deno/Bun environment
if (typeof process === 'undefined' || !isFunction(process?.cwd))
return undefined;

try {
// Use variables for dynamic imports to prevent bundlers from statically analyzing and failing
const modFs = 'node:fs';
const modUrl = 'node:url';
const modPath = 'node:path';

const [fs, path] = await Promise.all([
import(modFs),
import(modPath),
]);

let currentDir = options?.cwd || process.cwd();

const loadFile = async (configPath: string, ext: string) => {
if (ext === '.json') {
const content = fs.readFileSync(configPath, 'utf8');
return JSON.parse(content) as Options;
} else {
// Use pathToFileURL to safely load absolute paths on Windows
const { pathToFileURL } = await import(modUrl);
const imported = await import(pathToFileURL(configPath).href);
return imported.default || imported;
}
};

if (options?.configFile) {
const explicitPath = path.resolve(currentDir, options.configFile);
if (fs.existsSync(explicitPath)) {
try {
return await loadFile(explicitPath, path.extname(explicitPath));
} catch (err) {
console.warn(`[Tempo] Failed to load explicit config file at ${explicitPath}:`, err);
return undefined;
}
} else {
console.warn(`[Tempo] Explicit config file not found at ${explicitPath}`);
return undefined;
}
}

const rootPath = path.parse(currentDir).root;

while (currentDir !== rootPath) {
const pkgJson = path.join(currentDir, 'package.json');
const exts = ['.ts', '.js', '.mjs', '.cjs', '.json'];

for (const ext of exts) {
const configPath = path.join(currentDir, `tempo.config${ext}`);
if (fs.existsSync(configPath)) {
try {
return await loadFile(configPath, ext);
} catch (err) {
console.warn(`[Tempo] Found config file at ${configPath} but failed to load it:`, err);
return undefined;
}
}
}
Comment thread
magmacomputing marked this conversation as resolved.

if (fs.existsSync(pkgJson)) {
// Reached project root, stop searching
break;
}

currentDir = path.dirname(currentDir);
}
} catch (e) {
// Environment doesn't support node 'fs' or 'path', skip discovery
}

return undefined;
}
12 changes: 12 additions & 0 deletions packages/tempo/src/tempo.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,18 @@ export class Tempo {
return resolveDisplayStatus(rt.license.status);
}

/**
* Automatically discovers and loads configuration from `tempo.config.*`
* before initializing the engine.
*/
static async bootstrap(options?: { cwd?: string, configFile?: string }): Promise<typeof Tempo> {
const { resolveConfig } = await import('./config/resolveConfig.js');
const config = await resolveConfig(options);
this.init(config || {});
await this.ready();
return this;
}

/** Reset Tempo to its default, built-in registration state */
static init(options: t.Options = {}): typeof Tempo {
if (_lifecycle.initialising) return this;
Expand Down
1 change: 1 addition & 0 deletions packages/tempo/src/tempo.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Tempo.extend(core);

export { parse, format } from '#tempo/module';
export { enums };
export { defineConfig } from './config/defineConfig.js';

export * from './tempo.class.js';
export default Tempo;
2 changes: 1 addition & 1 deletion packages/tempo/src/tempo.version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
* ⚠️ This file is auto-updated by `npm run build:version` (see `bin/update-version.mjs`).
* Do NOT edit manually — your changes will be overwritten on the next build.
*/
export const TEMPO_VERSION = '3.2.2';
export const TEMPO_VERSION = '3.2.3';
63 changes: 63 additions & 0 deletions packages/tempo/template/index.sample.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello Tempo</title>
<!--
This is the sanctioned browser import map for Tempo.
Note: For production, you may want to bundle these or serve them from your own infrastructure.
-->
<script type="importmap">
{
"imports": {
"@js-temporal/polyfill": "https://cdn.jsdelivr.net/npm/@js-temporal/polyfill@0.5/dist/index.esm.js",
"@magmacomputing/tempo": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@3/dist/tempo.bundle.esm.js",
"@magmacomputing/tempo/core": "./node_modules/@magmacomputing/tempo/dist/core.index.js",
"@magmacomputing/tempo/duration": "./node_modules/@magmacomputing/tempo/dist/module/module.duration.js",
"@magmacomputing/tempo/mutate": "./node_modules/@magmacomputing/tempo/dist/module/module.mutate.js",
"@magmacomputing/tempo/plugin": "./node_modules/@magmacomputing/tempo/dist/plugin/plugin.index.js",
"@magmacomputing/tempo/library": "./node_modules/@magmacomputing/tempo/dist/library.index.js",
"@magmacomputing/tempo/enums": "./node_modules/@magmacomputing/tempo/dist/support/support.enum.js",
"#tempo/license": "./node_modules/@magmacomputing/tempo/dist/plugin/license/license.validator.js"
}
}
</script>
</head>

<body style="font-family: sans-serif; padding: 2rem;">
<h1>Tempo Hello World</h1>
<div id="output" style="padding: 1rem; background: #f0f0f0; border-radius: 8px;">
Initializing Tempo...
</div>

<script type="module">
import { Tempo } from '@magmacomputing/tempo';

async function bootstrap() {
const output = document.getElementById('output');
try {
// Initialize the Tempo engine.
// This will automatically look for 'tempo.config.ts' in modern environments,
// or you can pass config options directly to Tempo.init() instead.
await Tempo.bootstrap();

const now = new Tempo();
const formatted = now.format('{wkd}, {mon} {dd}, {yyyy} at {hh}:{mi}:{ss} {mer}');

output.innerHTML = `
<strong>Success!</strong> Tempo is initialized.<br><br>
Current Local Time: <span style="color: blue;">${formatted}</span>
`;
} catch (err) {
output.innerHTML = `<strong style="color: red;">Error initializing Tempo:</strong> ${err.message}`;
console.error(err);
}
}

bootstrap();
</script>
</body>

</html>
Loading
Loading