BREAKING CHANGE(tswatch): refactor tswatch to a config-driven design (load config from npmextra.json) and add interactive init wizard; change TsWatch public API and enhance Watcher behavior
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tswatch',
|
||||
version: '2.3.13',
|
||||
version: '3.0.0',
|
||||
description: 'A development tool for automatically watching and re-compiling TypeScript projects upon detecting file changes, enhancing developer workflows.'
|
||||
}
|
||||
|
||||
12
ts/index.ts
12
ts/index.ts
@@ -1,7 +1,11 @@
|
||||
import * as early from '@push.rocks/early';
|
||||
early.start('tswatch');
|
||||
export * from './tswatch.classes.tswatch.js';
|
||||
export * from './tswatch.cli.js';
|
||||
early.stop();
|
||||
|
||||
export * from './tswatch.classes.watcher.js';
|
||||
export * from './tswatch.classes.tswatch.js';
|
||||
export * from './tswatch.classes.watcher.js';
|
||||
export * from './tswatch.classes.confighandler.js';
|
||||
export * from './tswatch.cli.js';
|
||||
export * from './tswatch.init.js';
|
||||
export * from './interfaces/index.js';
|
||||
|
||||
early.stop();
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './interfaces.watchmodes.js';
|
||||
export * from './interfaces.config.js';
|
||||
|
||||
61
ts/interfaces/interfaces.config.ts
Normal file
61
ts/interfaces/interfaces.config.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Configuration for a single watcher
|
||||
*/
|
||||
export interface IWatcherConfig {
|
||||
/** Name for this watcher (used in logging) */
|
||||
name: string;
|
||||
/** Glob pattern(s) to watch */
|
||||
watch: string | string[];
|
||||
/** Shell command to execute on changes */
|
||||
command?: string;
|
||||
/** If true, kill previous process before restarting (default: true) */
|
||||
restart?: boolean;
|
||||
/** Debounce delay in ms (default: 300) */
|
||||
debounce?: number;
|
||||
/** If true, run the command immediately on start (default: true) */
|
||||
runOnStart?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the development server
|
||||
*/
|
||||
export interface IServerConfig {
|
||||
/** Whether the server is enabled */
|
||||
enabled: boolean;
|
||||
/** Server port (default: 3002) */
|
||||
port?: number;
|
||||
/** Directory to serve (default: ./dist_watch/) */
|
||||
serveDir?: string;
|
||||
/** Whether to inject live reload script (default: true) */
|
||||
liveReload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a bundle operation
|
||||
*/
|
||||
export interface IBundleConfig {
|
||||
/** Name for this bundle (used in logging) */
|
||||
name?: string;
|
||||
/** Entry point file */
|
||||
from: string;
|
||||
/** Output file */
|
||||
to: string;
|
||||
/** Additional patterns to watch that trigger this bundle */
|
||||
watchPatterns?: string[];
|
||||
/** If true, trigger server reload after bundling (default: true) */
|
||||
triggerReload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main tswatch configuration
|
||||
*/
|
||||
export interface ITswatchConfig {
|
||||
/** Array of watcher configurations */
|
||||
watchers?: IWatcherConfig[];
|
||||
/** Development server configuration */
|
||||
server?: IServerConfig;
|
||||
/** Bundle configurations */
|
||||
bundles?: IBundleConfig[];
|
||||
/** Use a preset configuration (overridden by explicit watchers/server/bundles) */
|
||||
preset?: 'element' | 'website' | 'npm' | 'service' | 'test';
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export type TWatchModes = 'test' | 'node' | 'service' | 'element' | 'website' | 'echo';
|
||||
185
ts/tswatch.classes.confighandler.ts
Normal file
185
ts/tswatch.classes.confighandler.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import * as plugins from './tswatch.plugins.js';
|
||||
import * as paths from './tswatch.paths.js';
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
|
||||
const CONFIG_KEY = '@git.zone/tswatch';
|
||||
|
||||
/**
|
||||
* Preset configurations matching legacy watch modes
|
||||
*/
|
||||
const presets: Record<string, interfaces.ITswatchConfig> = {
|
||||
npm: {
|
||||
watchers: [
|
||||
{
|
||||
name: 'npm-test',
|
||||
watch: ['./ts/**/*', './test/**/*'],
|
||||
command: 'npm run test',
|
||||
restart: true,
|
||||
debounce: 300,
|
||||
runOnStart: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
watchers: [
|
||||
{
|
||||
name: 'test2',
|
||||
watch: ['./ts/**/*', './test/**/*'],
|
||||
command: 'npm run test2',
|
||||
restart: true,
|
||||
debounce: 300,
|
||||
runOnStart: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
service: {
|
||||
watchers: [
|
||||
{
|
||||
name: 'service',
|
||||
watch: './ts/**/*',
|
||||
command: 'npm run startTs',
|
||||
restart: true,
|
||||
debounce: 300,
|
||||
runOnStart: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
element: {
|
||||
server: {
|
||||
enabled: true,
|
||||
port: 3002,
|
||||
serveDir: './dist_watch/',
|
||||
liveReload: true,
|
||||
},
|
||||
bundles: [
|
||||
{
|
||||
name: 'element-bundle',
|
||||
from: './html/index.ts',
|
||||
to: './dist_watch/bundle.js',
|
||||
watchPatterns: ['./ts_web/**/*'],
|
||||
triggerReload: true,
|
||||
},
|
||||
{
|
||||
name: 'html',
|
||||
from: './html/index.html',
|
||||
to: './dist_watch/index.html',
|
||||
watchPatterns: ['./html/**/*'],
|
||||
triggerReload: true,
|
||||
},
|
||||
],
|
||||
watchers: [
|
||||
{
|
||||
name: 'ts-build',
|
||||
watch: './ts/**/*',
|
||||
command: 'npm run build',
|
||||
restart: false,
|
||||
debounce: 300,
|
||||
runOnStart: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
website: {
|
||||
bundles: [
|
||||
{
|
||||
name: 'website-bundle',
|
||||
from: './ts_web/index.ts',
|
||||
to: './dist_serve/bundle.js',
|
||||
watchPatterns: ['./ts_web/**/*'],
|
||||
triggerReload: false,
|
||||
},
|
||||
{
|
||||
name: 'html',
|
||||
from: './html/index.html',
|
||||
to: './dist_serve/index.html',
|
||||
watchPatterns: ['./html/**/*'],
|
||||
triggerReload: false,
|
||||
},
|
||||
{
|
||||
name: 'assets',
|
||||
from: './assets/',
|
||||
to: './dist_serve/assets/',
|
||||
watchPatterns: ['./assets/**/*'],
|
||||
triggerReload: false,
|
||||
},
|
||||
],
|
||||
watchers: [
|
||||
{
|
||||
name: 'backend',
|
||||
watch: './ts/**/*',
|
||||
command: 'npm run startTs',
|
||||
restart: true,
|
||||
debounce: 300,
|
||||
runOnStart: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles loading and managing tswatch configuration
|
||||
*/
|
||||
export class ConfigHandler {
|
||||
private npmextra: plugins.npmextra.Npmextra;
|
||||
private cwd: string;
|
||||
|
||||
constructor(cwdArg?: string) {
|
||||
this.cwd = cwdArg || paths.cwd;
|
||||
this.npmextra = new plugins.npmextra.Npmextra(this.cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tswatch configuration exists
|
||||
*/
|
||||
public hasConfig(): boolean {
|
||||
const config = this.npmextra.dataFor<interfaces.ITswatchConfig>(CONFIG_KEY, null);
|
||||
return config !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from npmextra.json
|
||||
* If a preset is specified, merge preset defaults with user overrides
|
||||
*/
|
||||
public loadConfig(): interfaces.ITswatchConfig | null {
|
||||
const config = this.npmextra.dataFor<interfaces.ITswatchConfig>(CONFIG_KEY, null);
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If a preset is specified, merge it with user config
|
||||
if (config.preset && presets[config.preset]) {
|
||||
const preset = presets[config.preset];
|
||||
return {
|
||||
...preset,
|
||||
...config,
|
||||
// Merge arrays instead of replacing
|
||||
watchers: config.watchers || preset.watchers,
|
||||
bundles: config.bundles || preset.bundles,
|
||||
server: config.server !== undefined ? config.server : preset.server,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a preset configuration by name
|
||||
*/
|
||||
public getPreset(presetName: string): interfaces.ITswatchConfig | null {
|
||||
return presets[presetName] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available preset names
|
||||
*/
|
||||
public getPresetNames(): string[] {
|
||||
return Object.keys(presets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the config key for npmextra.json
|
||||
*/
|
||||
public getConfigKey(): string {
|
||||
return CONFIG_KEY;
|
||||
}
|
||||
}
|
||||
@@ -3,221 +3,185 @@ import * as paths from './tswatch.paths.js';
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
|
||||
import { Watcher } from './tswatch.classes.watcher.js';
|
||||
import { ConfigHandler } from './tswatch.classes.confighandler.js';
|
||||
import { logger } from './tswatch.logging.js';
|
||||
|
||||
// Create smartfs instance for directory operations
|
||||
const smartfs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
|
||||
|
||||
/**
|
||||
* Lists all folders in a directory
|
||||
* TsWatch - Config-driven file watcher
|
||||
*
|
||||
* Reads configuration from npmextra.json under the key '@git.zone/tswatch'
|
||||
* and sets up watchers, bundles, and dev server accordingly.
|
||||
*/
|
||||
const listFolders = async (dirPath: string): Promise<string[]> => {
|
||||
const entries = await smartfs.directory(dirPath).list();
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory)
|
||||
.map((entry) => entry.name);
|
||||
};
|
||||
|
||||
export class TsWatch {
|
||||
public watchmode: interfaces.TWatchModes;
|
||||
public config: interfaces.ITswatchConfig;
|
||||
public watcherMap = new plugins.lik.ObjectMap<Watcher>();
|
||||
public typedserver: plugins.typedserver.TypedServer;
|
||||
public typedserver: plugins.typedserver.TypedServer | null = null;
|
||||
|
||||
constructor(watchmodeArg: interfaces.TWatchModes) {
|
||||
this.watchmode = watchmodeArg;
|
||||
private tsbundle = new plugins.tsbundle.TsBundle();
|
||||
private htmlHandler = new plugins.tsbundle.HtmlHandler();
|
||||
private assetsHandler = new plugins.tsbundle.AssetsHandler();
|
||||
|
||||
constructor(configArg: interfaces.ITswatchConfig) {
|
||||
this.config = configArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create TsWatch from npmextra.json configuration
|
||||
*/
|
||||
public static fromConfig(cwdArg?: string): TsWatch | null {
|
||||
const configHandler = new ConfigHandler(cwdArg);
|
||||
const config = configHandler.loadConfig();
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TsWatch(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* starts the TsWatch instance
|
||||
*/
|
||||
public async start() {
|
||||
const tsbundle = new plugins.tsbundle.TsBundle();
|
||||
const assetsHandler = new plugins.tsbundle.AssetsHandler();
|
||||
const htmlHandler = new plugins.tsbundle.HtmlHandler();
|
||||
switch (this.watchmode) {
|
||||
case 'test':
|
||||
/**
|
||||
* this strategy runs test whenever there is a change in the ts directory
|
||||
*/
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
filePathToWatch: paths.cwd,
|
||||
commandToExecute: 'npm run test2',
|
||||
timeout: null,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'node':
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
filePathToWatch: paths.cwd,
|
||||
commandToExecute: 'npm run test',
|
||||
timeout: null,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'element':
|
||||
await (async () => {
|
||||
/**
|
||||
* this strategy runs a standard server and bundles the ts files to a dist_watch directory
|
||||
*/
|
||||
// lets create a standard server
|
||||
logger.log(
|
||||
'info',
|
||||
'bundling TypeScript files to "dist_watch" Note: This is for development only!',
|
||||
);
|
||||
this.typedserver = new plugins.typedserver.TypedServer({
|
||||
cors: true,
|
||||
injectReload: true,
|
||||
serveDir: plugins.path.join(paths.cwd, './dist_watch/'),
|
||||
port: 3002,
|
||||
compression: true,
|
||||
spaFallback: true,
|
||||
securityHeaders: {
|
||||
crossOriginOpenerPolicy: 'same-origin',
|
||||
crossOriginEmbedderPolicy: 'require-corp',
|
||||
},
|
||||
});
|
||||
logger.log('info', 'Starting tswatch with config-driven mode');
|
||||
|
||||
const bundleAndReloadElement = async () => {
|
||||
await tsbundle.build(paths.cwd, './html/index.ts', './dist_watch/bundle.js', {
|
||||
bundler: 'esbuild',
|
||||
});
|
||||
await this.typedserver.reload();
|
||||
};
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
filePathToWatch: plugins.path.join(paths.cwd, './ts_web/'),
|
||||
functionToCall: async () => {
|
||||
await bundleAndReloadElement();
|
||||
},
|
||||
timeout: null,
|
||||
}),
|
||||
);
|
||||
|
||||
// lets get the other ts folders
|
||||
let tsfolders = await listFolders(paths.cwd);
|
||||
tsfolders = tsfolders.filter(
|
||||
(itemArg) => itemArg.startsWith('ts') && itemArg !== 'ts_web',
|
||||
);
|
||||
const smartshellInstance = new plugins.smartshell.Smartshell({
|
||||
executor: 'bash',
|
||||
});
|
||||
for (const tsfolder of tsfolders) {
|
||||
logger.log('info', `creating watcher for folder ${tsfolder}`);
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
filePathToWatch: plugins.path.join(paths.cwd, `./${tsfolder}/`),
|
||||
functionToCall: async () => {
|
||||
logger.log('info', `building ${tsfolder}`);
|
||||
await smartshellInstance.exec(`(cd ${paths.cwd} && npm run build)`);
|
||||
await bundleAndReloadElement();
|
||||
},
|
||||
timeout: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
filePathToWatch: plugins.path.join(paths.cwd, './html/'),
|
||||
functionToCall: async () => {
|
||||
await htmlHandler.processHtml({
|
||||
from: plugins.path.join(paths.cwd, './html/index.html'),
|
||||
to: plugins.path.join(paths.cwd, './dist_watch/index.html'),
|
||||
minify: false,
|
||||
});
|
||||
await bundleAndReloadElement();
|
||||
},
|
||||
timeout: null,
|
||||
}),
|
||||
);
|
||||
})();
|
||||
break;
|
||||
case 'website':
|
||||
await (async () => {
|
||||
const websiteExecution = new plugins.smartshell.SmartExecution('npm run startTs');
|
||||
const bundleAndReloadWebsite = async () => {
|
||||
await tsbundle.build(paths.cwd, './ts_web/index.ts', './dist_serve/bundle.js', {
|
||||
bundler: 'esbuild',
|
||||
});
|
||||
};
|
||||
let tsfolders = await listFolders(paths.cwd);
|
||||
tsfolders = tsfolders.filter(
|
||||
(itemArg) => itemArg.startsWith('ts') && itemArg !== 'ts_web',
|
||||
);
|
||||
for (const tsfolder of tsfolders) {
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
filePathToWatch: plugins.path.join(paths.cwd, `./${tsfolder}/`),
|
||||
functionToCall: async () => {
|
||||
await websiteExecution.restart();
|
||||
await bundleAndReloadWebsite();
|
||||
},
|
||||
timeout: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
filePathToWatch: plugins.path.join(paths.cwd, './ts_web/'),
|
||||
functionToCall: async () => {
|
||||
await bundleAndReloadWebsite();
|
||||
},
|
||||
timeout: null,
|
||||
}),
|
||||
);
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
filePathToWatch: plugins.path.join(paths.cwd, './html/'),
|
||||
functionToCall: async () => {
|
||||
await htmlHandler.processHtml({
|
||||
from: plugins.path.join(paths.cwd, './html/index.html'),
|
||||
to: plugins.path.join(paths.cwd, './dist_serve/index.html'),
|
||||
minify: false,
|
||||
});
|
||||
await bundleAndReloadWebsite();
|
||||
},
|
||||
timeout: null,
|
||||
}),
|
||||
);
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
filePathToWatch: plugins.path.join(paths.cwd, './assets/'),
|
||||
functionToCall: async () => {
|
||||
await assetsHandler.processAssets();
|
||||
await bundleAndReloadWebsite();
|
||||
},
|
||||
timeout: null,
|
||||
}),
|
||||
);
|
||||
})();
|
||||
break;
|
||||
case 'service':
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
filePathToWatch: plugins.path.join(paths.cwd, './ts/'),
|
||||
commandToExecute: 'npm run startTs',
|
||||
timeout: null,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'echo':
|
||||
const tsWatchInstanceEchoSomething = new Watcher({
|
||||
filePathToWatch: plugins.path.join(paths.cwd, './ts'),
|
||||
commandToExecute: 'npm -v',
|
||||
timeout: null,
|
||||
});
|
||||
this.watcherMap.add(tsWatchInstanceEchoSomething);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
// Start server if configured
|
||||
if (this.config.server?.enabled) {
|
||||
await this.startServer();
|
||||
}
|
||||
this.watcherMap.forEach(async (watcher) => {
|
||||
|
||||
// Setup bundles and their watchers
|
||||
if (this.config.bundles && this.config.bundles.length > 0) {
|
||||
await this.setupBundles();
|
||||
}
|
||||
|
||||
// Setup watchers from config
|
||||
if (this.config.watchers && this.config.watchers.length > 0) {
|
||||
await this.setupWatchers();
|
||||
}
|
||||
|
||||
// Start all watchers
|
||||
await this.watcherMap.forEach(async (watcher) => {
|
||||
await watcher.start();
|
||||
});
|
||||
|
||||
// Start server after watchers are ready
|
||||
if (this.typedserver) {
|
||||
await this.typedserver.start();
|
||||
logger.log('ok', `Dev server started on port ${this.config.server?.port || 3002}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the development server
|
||||
*/
|
||||
private async startServer() {
|
||||
const serverConfig = this.config.server!;
|
||||
const port = serverConfig.port || 3002;
|
||||
const serveDir = serverConfig.serveDir || './dist_watch/';
|
||||
|
||||
logger.log('info', `Setting up dev server on port ${port}, serving ${serveDir}`);
|
||||
|
||||
this.typedserver = new plugins.typedserver.TypedServer({
|
||||
cors: true,
|
||||
injectReload: serverConfig.liveReload !== false,
|
||||
serveDir: plugins.path.join(paths.cwd, serveDir),
|
||||
port: port,
|
||||
compression: true,
|
||||
spaFallback: true,
|
||||
securityHeaders: {
|
||||
crossOriginOpenerPolicy: 'same-origin',
|
||||
crossOriginEmbedderPolicy: 'require-corp',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup bundle watchers
|
||||
*/
|
||||
private async setupBundles() {
|
||||
for (const bundleConfig of this.config.bundles!) {
|
||||
const name = bundleConfig.name || `bundle-${bundleConfig.from}`;
|
||||
logger.log('info', `Setting up bundle: ${name}`);
|
||||
|
||||
// Determine what patterns to watch
|
||||
const watchPatterns = bundleConfig.watchPatterns || [
|
||||
plugins.path.dirname(bundleConfig.from) + '/**/*',
|
||||
];
|
||||
|
||||
// Create the bundle function
|
||||
const bundleFunction = async () => {
|
||||
logger.log('info', `[${name}] bundling...`);
|
||||
|
||||
// Determine bundle type based on file extension
|
||||
const fromPath = bundleConfig.from;
|
||||
const toPath = bundleConfig.to;
|
||||
|
||||
if (fromPath.endsWith('.html')) {
|
||||
// HTML processing
|
||||
await this.htmlHandler.processHtml({
|
||||
from: plugins.path.join(paths.cwd, fromPath),
|
||||
to: plugins.path.join(paths.cwd, toPath),
|
||||
minify: false,
|
||||
});
|
||||
} else if (fromPath.endsWith('/') || !fromPath.includes('.')) {
|
||||
// Assets directory copy
|
||||
await this.assetsHandler.processAssets();
|
||||
} else {
|
||||
// TypeScript bundling
|
||||
await this.tsbundle.build(paths.cwd, fromPath, toPath, {
|
||||
bundler: 'esbuild',
|
||||
});
|
||||
}
|
||||
|
||||
logger.log('ok', `[${name}] bundle complete`);
|
||||
|
||||
// Trigger reload if configured and server is running
|
||||
if (bundleConfig.triggerReload !== false && this.typedserver) {
|
||||
await this.typedserver.reload();
|
||||
}
|
||||
};
|
||||
|
||||
// Run initial bundle
|
||||
await bundleFunction();
|
||||
|
||||
// Create watcher for this bundle
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
name: name,
|
||||
filePathToWatch: watchPatterns.map((p) => plugins.path.join(paths.cwd, p)),
|
||||
functionToCall: bundleFunction,
|
||||
runOnStart: false, // Already ran above
|
||||
debounce: 300,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup watchers from config
|
||||
*/
|
||||
private async setupWatchers() {
|
||||
for (const watcherConfig of this.config.watchers!) {
|
||||
logger.log('info', `Setting up watcher: ${watcherConfig.name}`);
|
||||
|
||||
// Convert watch paths to absolute
|
||||
const watchPaths = Array.isArray(watcherConfig.watch)
|
||||
? watcherConfig.watch
|
||||
: [watcherConfig.watch];
|
||||
|
||||
const absolutePaths = watchPaths.map((p) => plugins.path.join(paths.cwd, p));
|
||||
|
||||
this.watcherMap.add(
|
||||
new Watcher({
|
||||
name: watcherConfig.name,
|
||||
filePathToWatch: absolutePaths,
|
||||
commandToExecute: watcherConfig.command,
|
||||
restart: watcherConfig.restart ?? true,
|
||||
debounce: watcherConfig.debounce ?? 300,
|
||||
runOnStart: watcherConfig.runOnStart ?? true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +192,7 @@ export class TsWatch {
|
||||
if (this.typedserver) {
|
||||
await this.typedserver.stop();
|
||||
}
|
||||
this.watcherMap.forEach(async (watcher) => {
|
||||
await this.watcherMap.forEach(async (watcher) => {
|
||||
await watcher.stop();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import * as plugins from './tswatch.plugins.js';
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
import { logger } from './tswatch.logging.js';
|
||||
|
||||
export interface IWatcherConstructorOptions {
|
||||
filePathToWatch: string;
|
||||
/** Name for this watcher (used in logging) */
|
||||
name?: string;
|
||||
/** Path(s) to watch - can be a single path or array */
|
||||
filePathToWatch: string | string[];
|
||||
/** Shell command to execute on changes */
|
||||
commandToExecute?: string;
|
||||
/** Function to call on changes */
|
||||
functionToCall?: () => Promise<any>;
|
||||
/** Timeout for the watcher */
|
||||
timeout?: number;
|
||||
/** If true, kill previous process before restarting (default: true) */
|
||||
restart?: boolean;
|
||||
/** Debounce delay in ms (default: 300) */
|
||||
debounce?: number;
|
||||
/** If true, run the command immediately on start (default: true) */
|
||||
runOnStart?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,53 +35,148 @@ export class Watcher {
|
||||
private currentExecution: plugins.smartshell.IExecResultStreaming;
|
||||
private smartwatchInstance = new plugins.smartwatch.Smartwatch([]);
|
||||
private options: IWatcherConstructorOptions;
|
||||
private debounceTimer: NodeJS.Timeout | null = null;
|
||||
private isExecuting = false;
|
||||
private pendingExecution = false;
|
||||
|
||||
constructor(optionsArg: IWatcherConstructorOptions) {
|
||||
this.options = optionsArg;
|
||||
this.options = {
|
||||
restart: true,
|
||||
debounce: 300,
|
||||
runOnStart: true,
|
||||
...optionsArg,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Watcher from config
|
||||
*/
|
||||
public static fromConfig(config: interfaces.IWatcherConfig): Watcher {
|
||||
const watchPaths = Array.isArray(config.watch) ? config.watch : [config.watch];
|
||||
return new Watcher({
|
||||
name: config.name,
|
||||
filePathToWatch: watchPaths,
|
||||
commandToExecute: config.command,
|
||||
restart: config.restart ?? true,
|
||||
debounce: config.debounce ?? 300,
|
||||
runOnStart: config.runOnStart ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the watcher name for logging
|
||||
*/
|
||||
private getName(): string {
|
||||
return this.options.name || 'unnamed';
|
||||
}
|
||||
|
||||
/**
|
||||
* start the file
|
||||
*/
|
||||
public async start() {
|
||||
logger.log('info', `trying to start watcher for ${this.options.filePathToWatch}`);
|
||||
const name = this.getName();
|
||||
logger.log('info', `[${name}] starting watcher`);
|
||||
await this.setupCleanup();
|
||||
console.log(`Looking at ${this.options.filePathToWatch} for changes`);
|
||||
// Convert directory path to glob pattern for smartwatch
|
||||
const watchPath = this.options.filePathToWatch.endsWith('/')
|
||||
? `${this.options.filePathToWatch}**/*`
|
||||
: `${this.options.filePathToWatch}/**/*`;
|
||||
this.smartwatchInstance.add([watchPath]);
|
||||
|
||||
// Convert paths to glob patterns
|
||||
const paths = Array.isArray(this.options.filePathToWatch)
|
||||
? this.options.filePathToWatch
|
||||
: [this.options.filePathToWatch];
|
||||
|
||||
const watchPatterns = paths.map((p) => {
|
||||
// Convert directory path to glob pattern for smartwatch
|
||||
if (p.endsWith('/')) {
|
||||
return `${p}**/*`;
|
||||
}
|
||||
// If it's already a glob pattern, use as-is
|
||||
if (p.includes('*')) {
|
||||
return p;
|
||||
}
|
||||
// Otherwise assume it's a directory
|
||||
return `${p}/**/*`;
|
||||
});
|
||||
|
||||
logger.log('info', `[${name}] watching patterns: ${watchPatterns.join(', ')}`);
|
||||
this.smartwatchInstance.add(watchPatterns);
|
||||
await this.smartwatchInstance.start();
|
||||
|
||||
const changeObservable = await this.smartwatchInstance.getObservableFor('change');
|
||||
changeObservable.subscribe(() => {
|
||||
this.updateCurrentExecution();
|
||||
this.handleChange();
|
||||
});
|
||||
await this.updateCurrentExecution();
|
||||
logger.log('info', `watcher started for ${this.options.filePathToWatch}`);
|
||||
|
||||
// Run on start if configured
|
||||
if (this.options.runOnStart) {
|
||||
await this.executeCommand();
|
||||
}
|
||||
|
||||
logger.log('info', `[${name}] watcher started`);
|
||||
}
|
||||
|
||||
/**
|
||||
* updates the current execution
|
||||
* Handle file change with debouncing
|
||||
*/
|
||||
private async updateCurrentExecution() {
|
||||
if (this.options.commandToExecute) {
|
||||
if (this.currentExecution) {
|
||||
logger.log('ok', `reexecuting ${this.options.commandToExecute}`);
|
||||
this.currentExecution.kill();
|
||||
} else {
|
||||
logger.log('ok', `executing ${this.options.commandToExecute} for the first time`);
|
||||
private handleChange() {
|
||||
const name = this.getName();
|
||||
|
||||
// Clear existing debounce timer
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
// Set new debounce timer
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
this.debounceTimer = null;
|
||||
|
||||
// If currently executing and not in restart mode, mark pending
|
||||
if (this.isExecuting && !this.options.restart) {
|
||||
logger.log('info', `[${name}] change detected, queuing execution`);
|
||||
this.pendingExecution = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.executeCommand();
|
||||
|
||||
// If there was a pending execution, run it
|
||||
if (this.pendingExecution) {
|
||||
this.pendingExecution = false;
|
||||
await this.executeCommand();
|
||||
}
|
||||
}, this.options.debounce);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the command or function
|
||||
*/
|
||||
private async executeCommand() {
|
||||
const name = this.getName();
|
||||
|
||||
if (this.options.commandToExecute) {
|
||||
if (this.currentExecution && this.options.restart) {
|
||||
logger.log('ok', `[${name}] restarting: ${this.options.commandToExecute}`);
|
||||
this.currentExecution.kill();
|
||||
} else if (!this.currentExecution) {
|
||||
logger.log('ok', `[${name}] executing: ${this.options.commandToExecute}`);
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
this.currentExecution = await this.smartshellInstance.execStreaming(
|
||||
this.options.commandToExecute,
|
||||
);
|
||||
} else {
|
||||
console.log('no executionCommand set');
|
||||
|
||||
// Track when execution completes
|
||||
this.currentExecution.childProcess.on('exit', () => {
|
||||
this.isExecuting = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (this.options.functionToCall) {
|
||||
this.options.functionToCall();
|
||||
} else {
|
||||
console.log('no functionToCall set.');
|
||||
this.isExecuting = true;
|
||||
try {
|
||||
await this.options.functionToCall();
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +211,9 @@ export class Watcher {
|
||||
* stops the watcher
|
||||
*/
|
||||
public async stop() {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
await this.smartwatchInstance.stop();
|
||||
if (this.currentExecution && !this.currentExecution.childProcess.killed) {
|
||||
this.currentExecution.kill();
|
||||
|
||||
@@ -3,42 +3,48 @@ import * as paths from './tswatch.paths.js';
|
||||
import { logger } from './tswatch.logging.js';
|
||||
|
||||
import { TsWatch } from './tswatch.classes.tswatch.js';
|
||||
import { ConfigHandler } from './tswatch.classes.confighandler.js';
|
||||
import { runInit } from './tswatch.init.js';
|
||||
|
||||
const tswatchCli = new plugins.smartcli.Smartcli();
|
||||
|
||||
// standard behaviour will assume gitzone setup
|
||||
tswatchCli.standardCommand().subscribe((argvArg) => {
|
||||
tswatchCli.triggerCommand('npm', {});
|
||||
/**
|
||||
* Standard command (no args) - run with config or launch wizard
|
||||
*/
|
||||
tswatchCli.standardCommand().subscribe(async (argvArg) => {
|
||||
const configHandler = new ConfigHandler();
|
||||
|
||||
if (configHandler.hasConfig()) {
|
||||
// Config exists - run with it
|
||||
const tsWatch = TsWatch.fromConfig();
|
||||
if (tsWatch) {
|
||||
logger.log('info', 'Starting tswatch with configuration from npmextra.json');
|
||||
await tsWatch.start();
|
||||
} else {
|
||||
logger.log('error', 'Failed to load configuration');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// No config - launch wizard
|
||||
logger.log('info', 'No tswatch configuration found in npmextra.json');
|
||||
const config = await runInit();
|
||||
if (config) {
|
||||
// Run with the newly created config
|
||||
const tsWatch = new TsWatch(config);
|
||||
await tsWatch.start();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tswatchCli.addCommand('element').subscribe(async (argvArg) => {
|
||||
logger.log('info', `running watch task for a gitzone element project`);
|
||||
const tsWatch = new TsWatch('element');
|
||||
await tsWatch.start();
|
||||
});
|
||||
|
||||
tswatchCli.addCommand('npm').subscribe(async (argvArg) => {
|
||||
logger.log('info', `running watch task for a gitzone element project`);
|
||||
const tsWatch = new TsWatch('node');
|
||||
await tsWatch.start();
|
||||
});
|
||||
|
||||
tswatchCli.addCommand('service').subscribe(async (argvArg) => {
|
||||
logger.log('info', `running test task`);
|
||||
const tsWatch = new TsWatch('service');
|
||||
await tsWatch.start();
|
||||
});
|
||||
|
||||
tswatchCli.addCommand('test').subscribe(async (argvArg) => {
|
||||
logger.log('info', `running test task`);
|
||||
const tsWatch = new TsWatch('test');
|
||||
await tsWatch.start();
|
||||
});
|
||||
|
||||
tswatchCli.addCommand('website').subscribe(async (argvArg) => {
|
||||
logger.log('info', `running watch task for a gitzone website project`);
|
||||
const tsWatch = new TsWatch('website');
|
||||
await tsWatch.start();
|
||||
/**
|
||||
* Init command - force run wizard (overwrite existing config)
|
||||
*/
|
||||
tswatchCli.addCommand('init').subscribe(async (argvArg) => {
|
||||
logger.log('info', 'Running tswatch configuration wizard');
|
||||
const config = await runInit();
|
||||
if (config) {
|
||||
logger.log('ok', 'Configuration created successfully');
|
||||
}
|
||||
});
|
||||
|
||||
export const runCli = async () => {
|
||||
|
||||
199
ts/tswatch.init.ts
Normal file
199
ts/tswatch.init.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import * as plugins from './tswatch.plugins.js';
|
||||
import * as paths from './tswatch.paths.js';
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
import { ConfigHandler } from './tswatch.classes.confighandler.js';
|
||||
import { logger } from './tswatch.logging.js';
|
||||
|
||||
const CONFIG_KEY = '@git.zone/tswatch';
|
||||
|
||||
/**
|
||||
* Interactive init wizard for creating tswatch configuration
|
||||
*/
|
||||
export class TswatchInit {
|
||||
private configHandler: ConfigHandler;
|
||||
private smartInteract: plugins.smartinteract.SmartInteract;
|
||||
|
||||
constructor() {
|
||||
this.configHandler = new ConfigHandler();
|
||||
this.smartInteract = new plugins.smartinteract.SmartInteract([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive init wizard
|
||||
*/
|
||||
public async run(): Promise<interfaces.ITswatchConfig | null> {
|
||||
console.log('\n=== tswatch Configuration Wizard ===\n');
|
||||
|
||||
// Ask for template choice
|
||||
const templateAnswer = await this.smartInteract.askQuestion({
|
||||
name: 'template',
|
||||
type: 'list',
|
||||
message: 'Select a configuration template:',
|
||||
default: 'npm',
|
||||
choices: [
|
||||
{ name: 'npm - Watch ts/ and test/, run npm test', value: 'npm' },
|
||||
{ name: 'test - Watch ts/ and test/, run npm run test2', value: 'test' },
|
||||
{ name: 'service - Watch ts/, restart npm run startTs', value: 'service' },
|
||||
{ name: 'element - Dev server + bundling for web components', value: 'element' },
|
||||
{ name: 'website - Full stack: backend + frontend + assets', value: 'website' },
|
||||
{ name: 'custom - Configure watchers manually', value: 'custom' },
|
||||
],
|
||||
});
|
||||
|
||||
const template = templateAnswer.value as string;
|
||||
|
||||
let config: interfaces.ITswatchConfig;
|
||||
|
||||
if (template === 'custom') {
|
||||
config = await this.runCustomWizard();
|
||||
} else {
|
||||
// Get preset config
|
||||
const preset = this.configHandler.getPreset(template);
|
||||
if (!preset) {
|
||||
console.error(`Unknown template: ${template}`);
|
||||
return null;
|
||||
}
|
||||
config = { ...preset, preset: template as interfaces.ITswatchConfig['preset'] };
|
||||
}
|
||||
|
||||
// Save to npmextra.json
|
||||
await this.saveConfig(config);
|
||||
|
||||
console.log('\nConfiguration saved to npmextra.json');
|
||||
console.log('Run "tswatch" to start watching.\n');
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run custom configuration wizard
|
||||
*/
|
||||
private async runCustomWizard(): Promise<interfaces.ITswatchConfig> {
|
||||
const config: interfaces.ITswatchConfig = {};
|
||||
|
||||
// Ask about server
|
||||
const serverAnswer = await this.smartInteract.askQuestion({
|
||||
name: 'enableServer',
|
||||
type: 'confirm',
|
||||
message: 'Enable development server?',
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (serverAnswer.value) {
|
||||
const portAnswer = await this.smartInteract.askQuestion({
|
||||
name: 'port',
|
||||
type: 'input',
|
||||
message: 'Server port:',
|
||||
default: '3002',
|
||||
});
|
||||
|
||||
const serveDirAnswer = await this.smartInteract.askQuestion({
|
||||
name: 'serveDir',
|
||||
type: 'input',
|
||||
message: 'Directory to serve:',
|
||||
default: './dist_watch/',
|
||||
});
|
||||
|
||||
config.server = {
|
||||
enabled: true,
|
||||
port: parseInt(portAnswer.value as string, 10),
|
||||
serveDir: serveDirAnswer.value as string,
|
||||
liveReload: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Add watchers
|
||||
config.watchers = [];
|
||||
let addMore = true;
|
||||
|
||||
while (addMore) {
|
||||
console.log('\n--- Add a watcher ---');
|
||||
|
||||
const nameAnswer = await this.smartInteract.askQuestion({
|
||||
name: 'name',
|
||||
type: 'input',
|
||||
message: 'Watcher name:',
|
||||
default: `watcher-${config.watchers.length + 1}`,
|
||||
});
|
||||
|
||||
const watchAnswer = await this.smartInteract.askQuestion({
|
||||
name: 'watch',
|
||||
type: 'input',
|
||||
message: 'Glob pattern(s) to watch (comma-separated):',
|
||||
default: './ts/**/*',
|
||||
});
|
||||
|
||||
const commandAnswer = await this.smartInteract.askQuestion({
|
||||
name: 'command',
|
||||
type: 'input',
|
||||
message: 'Command to execute:',
|
||||
default: 'npm run test',
|
||||
});
|
||||
|
||||
const restartAnswer = await this.smartInteract.askQuestion({
|
||||
name: 'restart',
|
||||
type: 'confirm',
|
||||
message: 'Restart command on each change (vs queue)?',
|
||||
default: true,
|
||||
});
|
||||
|
||||
// Parse watch patterns
|
||||
const watchPatterns = (watchAnswer.value as string)
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0);
|
||||
|
||||
config.watchers.push({
|
||||
name: nameAnswer.value as string,
|
||||
watch: watchPatterns.length === 1 ? watchPatterns[0] : watchPatterns,
|
||||
command: commandAnswer.value as string,
|
||||
restart: restartAnswer.value as boolean,
|
||||
debounce: 300,
|
||||
runOnStart: true,
|
||||
});
|
||||
|
||||
const moreAnswer = await this.smartInteract.askQuestion({
|
||||
name: 'addMore',
|
||||
type: 'confirm',
|
||||
message: 'Add another watcher?',
|
||||
default: false,
|
||||
});
|
||||
|
||||
addMore = moreAnswer.value as boolean;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save configuration to npmextra.json
|
||||
*/
|
||||
private async saveConfig(config: interfaces.ITswatchConfig): Promise<void> {
|
||||
const npmextraPath = plugins.path.join(paths.cwd, 'npmextra.json');
|
||||
|
||||
// Read existing npmextra.json if it exists
|
||||
let existingConfig: Record<string, any> = {};
|
||||
try {
|
||||
const smartfsInstance = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
|
||||
const content = await smartfsInstance.file(npmextraPath).encoding('utf8').read() as string;
|
||||
existingConfig = JSON.parse(content);
|
||||
} catch {
|
||||
// File doesn't exist or is invalid, start fresh
|
||||
}
|
||||
|
||||
// Update with new tswatch config
|
||||
existingConfig[CONFIG_KEY] = config;
|
||||
|
||||
// Write back
|
||||
const smartfsInstance = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
|
||||
await smartfsInstance.file(npmextraPath).encoding('utf8').write(JSON.stringify(existingConfig, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the init wizard
|
||||
*/
|
||||
export const runInit = async (): Promise<interfaces.ITswatchConfig | null> => {
|
||||
const init = new TswatchInit();
|
||||
return init.run();
|
||||
};
|
||||
@@ -2,20 +2,22 @@
|
||||
import * as path from 'path';
|
||||
export { path };
|
||||
|
||||
// @gitzone scope
|
||||
// @git.zone scope
|
||||
import * as tsbundle from '@git.zone/tsbundle';
|
||||
export { tsbundle };
|
||||
|
||||
// @apiglobal scope
|
||||
// @api.global scope
|
||||
import * as typedserver from '@api.global/typedserver';
|
||||
|
||||
export { typedserver };
|
||||
|
||||
// @pushrocks scope
|
||||
// @push.rocks scope
|
||||
import * as lik from '@push.rocks/lik';
|
||||
import * as npmextra from '@push.rocks/npmextra';
|
||||
import * as smartcli from '@push.rocks/smartcli';
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
import * as smartfs from '@push.rocks/smartfs';
|
||||
import * as smartinteract from '@push.rocks/smartinteract';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartlogDestinationLocal from '@push.rocks/smartlog-destination-local';
|
||||
import * as smartshell from '@push.rocks/smartshell';
|
||||
@@ -24,9 +26,11 @@ import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||
|
||||
export {
|
||||
lik,
|
||||
npmextra,
|
||||
smartcli,
|
||||
smartdelay,
|
||||
smartfs,
|
||||
smartinteract,
|
||||
smartlog,
|
||||
smartlogDestinationLocal,
|
||||
smartshell,
|
||||
|
||||
Reference in New Issue
Block a user