Created
May 5, 2023 21:25
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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.`); | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export async function touchFile(filePath: string): Promise<void> | |
{ | |
const fs = await import('fs/promises'); | |
const now = new Date(); | |
return fs.utimes(filePath, now, now); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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