Skip to content

Instantly share code, notes, and snippets.

@BeSpunky
Created May 5, 2023 21:25
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save BeSpunky/d40bf6052b2c9a4f3a58b22108e5124a to your computer and use it in GitHub Desktop.
ScriptKit Script: Watch libs and run a partial rebuild of the dependency graph when a lib file is changed, then trigger update of dependant scripts.
// Name: Watch Libs
// Description: Watch libs and run a partial rebuild of the dependency graph when a lib file is changed, then trigger update of dependant scripts.
// Watch: ../lib/**/*.ts
// Author: Shy Agam
// Twitter: @shyagam
import "@johnlindquist/kit"
import { ensureIsTsFilePath } from '../lib/utils';
import { ensureIsWatchChangeEvent } from '../lib/utils';
import { ScriptDependencyGraphManager } from '../lib/dependency-graph';
import { touchFile } from '../lib/file-system';
const triggeringFile = await arg('Which file was changed?');
const event = await arg('What was the event?');
log('👀 A lib file has been changed.');
ensureIsWatchChangeEvent(event);
ensureIsTsFilePath(triggeringFile);
log('⛓️ Running dependency graph update...');
await run('update-scripts-dependency-graph.ts', triggeringFile);
log('✅ Graph updated! Looking for dependent scripts...');
const graphManager = await ScriptDependencyGraphManager.init();
const scriptsToUpdate = await graphManager.getDependantScripts(triggeringFile);
if (scriptsToUpdate.length === 0)
{
log('👍 No scripts to update. Exiting...');
exit();
}
log(`🏗️ Triggering update of ${ scriptsToUpdate.length } scripts...`);
await Promise.all(scriptsToUpdate.map(touchFile));
log(`✅ Scripts updated.`);
import { TsFilePath } from './types/paths.types';
import { isTsFilePath } from './utils';
export type ScriptDependencyGraph = Record<TsFilePath, TsFilePath[]>;
const DbName = '_update-scripts-dependency-graph';
export class ScriptDependencyGraphManager
{
private static storage: ReturnType<typeof db<{ graph: ScriptDependencyGraph }>> extends Promise<infer T> ? T : never;
private static graph: ScriptDependencyGraph;
private static instance: ScriptDependencyGraphManager;
public static async init(): Promise<ScriptDependencyGraphManager>
{
this.storage ??= await db(DbName, { graph: {} as ScriptDependencyGraph });
this.graph ??= this.storage.graph;
this.instance ??= new ScriptDependencyGraphManager();
return this.instance;
}
public async getDependantScripts(libraryPath: TsFilePath): Promise<TsFilePath[]>
{
return ScriptDependencyGraphManager.graph[libraryPath] ?? [];
}
public async updateDependantScripts(libraryPath: TsFilePath, dependantScriptPaths: TsFilePath[], saveIt: boolean = true): Promise<ScriptDependencyGraph>
{
// Aggregate instead of overwriting the library's dependencies to make sure we don't lose any dependencies that were not discovered in the current run.
const existingUnrelatedScriptPaths = (ScriptDependencyGraphManager.graph[libraryPath] ?? []).filter(path => !dependantScriptPaths.includes(path));
ScriptDependencyGraphManager.graph[libraryPath] = [...new Set([...existingUnrelatedScriptPaths, ...dependantScriptPaths])];
saveIt && ScriptDependencyGraphManager.storage.write();
return ScriptDependencyGraphManager.graph;
}
public async rebuild(saveIt: boolean = true): Promise<ScriptDependencyGraph>
{
const scriptPaths = await getAllTsScripts();
const graph = await createDependencyGraph(scriptPaths);
ScriptDependencyGraphManager.graph = graph;
ScriptDependencyGraphManager.storage.graph = graph;
saveIt && ScriptDependencyGraphManager.storage.write();
return graph;
}
public async partialRebuild(libOrScriptPath: TsFilePath, saveIt: boolean = true): Promise<ScriptDependencyGraph>
{
const isScriptPath = (/.*\.kenv[\/\\]scripts[\/\\].*\.ts$/g.test(libOrScriptPath))
const scriptPaths = isScriptPath ? [libOrScriptPath] : await this.getDependantScripts(libOrScriptPath);
const partialGraph = await createDependencyGraph(scriptPaths);
await Promise.all(
Object.entries(partialGraph)
.map(([libraryPath, dependantScriptPaths]) => this.updateDependantScripts(libraryPath as TsFilePath, dependantScriptPaths, false))
);
saveIt && this.save();
return ScriptDependencyGraphManager.graph;
}
public async save(): Promise<void>
{
ScriptDependencyGraphManager.storage.write();
}
}
export async function getAllTsScripts(): Promise<TsFilePath[]>
{
const scripts = await readdir('./scripts');
return scripts.filter(isTsFilePath)
.map(fileName => kenvPath('scripts', fileName) as TsFilePath);
}
export async function extractImportsFromFile(path: TsFilePath): Promise<TsFilePath[]>
{
const scriptContent = await readFile(path, 'utf-8');
const libImportRegex = /import.+from ['"]\.\.\/lib\/(?<dependency>.*)['"]/gm;
const libs = [];
let match: RegExpMatchArray | null;
while ((match = libImportRegex.exec(scriptContent)) !== null)
{
// This is necessary to avoid infinite loops with zero-width matches
if (match.index === libImportRegex.lastIndex)
++libImportRegex.lastIndex;
libs.push(match.groups?.dependency);
}
return libs ?? [];
}
export function toLibFilePath(libImportExpression: string, relative: boolean = false): TsFilePath
{
return relative
? `../lib/${ libImportExpression }.ts`
: kenvPath('lib', `${ libImportExpression }.ts`) as TsFilePath;
}
export async function extractDependenciesRecoursively(path: TsFilePath, visited: Set<TsFilePath> = new Set()): Promise<TsFilePath[]>
{
if (visited.has(path)) return [];
visited.add(path);
const discoveredImports = await extractImportsFromFile(path);
if (!discoveredImports.length) return [];
const guessedFilePaths = discoveredImports.map(dep => toLibFilePath(dep));
const tsFiles = await filterNonExistingFiles(guessedFilePaths);
const dependencies = await Promise.all(tsFiles.map(tsFile => extractDependenciesRecoursively(tsFile, visited)));
return [...discoveredImports, ...dependencies.flat()];
}
export async function filterNonExistingFiles(libFilePaths: TsFilePath[])
{
const existingFiles = [];
for (const libFilePath of libFilePaths)
await isFile(libFilePath) && existingFiles.push(libFilePath);
return existingFiles;
}
export async function createDependencyGraph(scriptPaths: TsFilePath[]): Promise<ScriptDependencyGraph>
{
const dependencyGraph: ScriptDependencyGraph = {};
for (const scriptPath of scriptPaths)
{
const dependencies = await extractDependenciesRecoursively(scriptPath);
for (const dependency of dependencies)
(dependencyGraph[toLibFilePath(dependency)] ??= []).push(scriptPath);
}
return dependencyGraph;
}
export async function touchFile(filePath: string): Promise<void>
{
const fs = await import('fs/promises');
const now = new Date();
return fs.utimes(filePath, now, now);
}
import type { Choice, Script } from '@johnlindquist/kit';
import type { TsFilePath } from './types/paths.types';
export function getRandomItemFromArray<T>(array: T[]): T
{
return array[Math.floor(Math.random() * array.length)];
}
export function randomId(): number
{
return Math.round(Math.random() * 1000000000);
}
export function isTsFilePath(fileName: string): fileName is TsFilePath
{
return fileName.endsWith('.ts');
}
export function isTsScript(script: Script): script is Script & { filePath: TsFilePath }
{
return isTsFilePath(script.filePath);
}
export function ensureIsTsFilePath(path: string, message?: string): asserts path is TsFilePath
{
if (!isTsFilePath(path))
{
console.log(message ?? `🤔 '${ path }' is not a ts file. Exiting...`);
exit();
}
}
export function ensureIsWatchChangeEvent(event: string): asserts event is 'change'
{
if (event !== 'change')
{
console.log(`🤔 '${ event }' is not a watch change event. Exiting...`);
exit();
}
}
interface ScriptChoicesConfig<ValueAs extends keyof Script | ((script: Script) => any)>
{
scripts: Script[];
valueAs: ValueAs;
tsOnly: boolean;
addPreview: boolean
}
type ScriptChoice<Value extends keyof Script | ((script: Script) => any)> = Choice<
Value extends keyof Script ? Script[Value] :
Value extends (script: Script) => any ? ReturnType<Value> : never
>
export async function scriptChoices<ValueAs extends keyof Script | ((script: Script) => any)>(
{
scripts,
valueAs,
tsOnly = true,
addPreview = true
}: Partial<ScriptChoicesConfig<ValueAs>> = {}
): Promise<ScriptChoice<ValueAs>[]>
{
scripts ??= await getScripts();
const createValue = (script: Script) =>
typeof valueAs === 'function'
? valueAs(script)
: script[valueAs as keyof Script ?? 'fullPath'];
return (tsOnly ? scripts.filter(isTsScript) : scripts)
.map(script => ({
name: script.name,
description: script.description,
value: createValue(script),
preview: addPreview ? () => highlightScript(script) : undefined
}));
}
export async function highlightScript(script: Script, language: string = 'ts'): Promise<string>
{
const scriptContent = await readFile(script.filePath, 'utf-8');
const markdown = `
\`\`\`${ language }
${ scriptContent }
\`\`\`
`;
return highlight(md(markdown, 'p-0 m-0 h-full w-full'));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment