diff --git a/package-lock.json b/package-lock.json index 45932ef..36f497c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "as-table": "^1.0.55", "chalk": "^4.1.0", "chokidar": "^3.5.1", + "ci-info": "^1.6.0", "cli-belt": "^1.0.3", "common-tags": "^1.8.0", "debounce": "^1.2.1", @@ -27,7 +28,7 @@ "type-core": "^0.8.0" }, "bin": { - "kpo": "dist/bin/kpo.js" + "kpo": "dist/cli/kpo.js" }, "devDependencies": { "@pika/pack": "^0.4.0", @@ -3692,8 +3693,7 @@ "node_modules/ci-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz", - "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==", - "dev": true + "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==" }, "node_modules/class-utils": { "version": "0.3.6", @@ -20903,8 +20903,7 @@ "ci-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz", - "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==", - "dev": true + "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==" }, "class-utils": { "version": "0.3.6", diff --git a/package.json b/package.json index 03f7912..f0e061f 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "as-table": "^1.0.55", "chalk": "^4.1.0", "chokidar": "^3.5.1", + "ci-info": "^1.6.0", "cli-belt": "^1.0.3", "common-tags": "^1.8.0", "debounce": "^1.2.1", diff --git a/src/cli/bin/main.ts b/src/cli/bin/main.ts index a0ecce7..0eb0638 100644 --- a/src/cli/bin/main.ts +++ b/src/cli/bin/main.ts @@ -33,6 +33,7 @@ export function main(argv: string[], options: Required): Task { -e, --env Environment variables --level Logging level --prefix Prefix tasks output with their route + --non-interactive Set the context as non-interactive -h, --help Show help -v, --version Show version number @@ -67,6 +68,7 @@ export function main(argv: string[], options: Required): Task { '--env': [String] as [StringConstructor], '--level': String, '--prefix': Boolean, + '--non-interactive': Boolean, '--help': Boolean, '--version': Boolean }; @@ -90,6 +92,7 @@ export function main(argv: string[], options: Required): Task { file: cmd['--file'], directory: cmd['--dir'], prefix: cmd['--prefix'], + nonInteractive: cmd['--non-interactive'], level: cmd['--level'] && constants.collections.levels.includes(cmd['--level'].toLowerCase()) @@ -186,9 +189,10 @@ export function main(argv: string[], options: Required): Task { }; }, context.bind(null, { + env: opts.env, level: opts.level, prefix: opts.prefix, - env: opts.env + interactive: !opts.nonInteractive }) ); } diff --git a/src/definitions/tasks.ts b/src/definitions/tasks.ts index 77ffd1a..eed4573 100644 --- a/src/definitions/tasks.ts +++ b/src/definitions/tasks.ts @@ -54,6 +54,10 @@ export interface Context { * with the stringification of its route. */ readonly prefix: PrefixPolicy | boolean; + /** + * Sets a context as non-interactive. + */ + readonly interactive: boolean; /** * A *Promise* representing a task's * cancellation token. diff --git a/src/helpers/create-context.ts b/src/helpers/create-context.ts index 73b2814..58e7c9f 100644 --- a/src/helpers/create-context.ts +++ b/src/helpers/create-context.ts @@ -1,6 +1,7 @@ import { Context } from '../definitions'; import { constants } from '../constants'; import { into } from 'pipettes'; +import { TypeGuard } from 'type-core'; const cancellation = new Promise(() => undefined); @@ -15,6 +16,9 @@ export function createContext(context?: Partial): Context { level: context.level || constants.defaults.level, route: context.route || [], prefix: context.prefix || false, + interactive: TypeGuard.isUndefined(context.interactive) + ? true + : context.interactive, cancellation: context.cancellation ? context.cancellation.then(() => undefined) : cancellation diff --git a/src/tasks/stdio/index.ts b/src/tasks/stdio/index.ts index e391fed..76400d5 100644 --- a/src/tasks/stdio/index.ts +++ b/src/tasks/stdio/index.ts @@ -1,5 +1,6 @@ export * from './announce'; export * from './clear'; +export * from './interactive'; export * from './log'; export * from './print'; export * from './select'; diff --git a/src/tasks/stdio/interactive.ts b/src/tasks/stdio/interactive.ts new file mode 100644 index 0000000..5f0d859 --- /dev/null +++ b/src/tasks/stdio/interactive.ts @@ -0,0 +1,35 @@ +import { Task, Context } from '../../definitions'; +import { isInteractive } from '../../utils/is-interactive'; +import { run } from '../../utils/run'; +import { raises } from '../exception/raises'; +import { log } from './log'; +import { Empty } from 'type-core'; +import { into } from 'pipettes'; + +/** + * Marks a task as interactive. + * Will error on non-interactive environments + * unless an `alternate` task is provided. + * @returns Task + */ +export function interactive(task: Task, alternate: Task | Empty): Task.Async { + return async (ctx: Context): Promise => { + const interactive = isInteractive(ctx); + + into( + ctx, + log( + 'debug', + interactive ? 'Interactive' : 'Non-interactive', + 'environment detected' + ) + ); + + return run( + interactive + ? task + : alternate || raises(Error('Non-interactive environment detected')), + ctx + ); + }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 5bf4036..a89df0e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,6 @@ export * from './fetch'; export * from './is-cancelled'; +export * from './is-ci'; +export * from './is-interactive'; export * from './recreate'; export * from './run'; diff --git a/src/utils/is-ci.ts b/src/utils/is-ci.ts new file mode 100644 index 0000000..e2584ab --- /dev/null +++ b/src/utils/is-ci.ts @@ -0,0 +1,32 @@ +import { Context } from '../definitions'; +import { TypeGuard } from 'type-core'; +import vendors from 'ci-info/vendors.json'; + +/** + * Returns `true` when a context's environment + * variables indicate it's running in a CI. + */ +export function isCI(context: Context): boolean { + if ('CI' in context.env || 'CONTINUOUS_INTEGRATION' in context.env) { + return true; + } + + const arr = vendors.map((vendor) => vendor.env); + + for (const env of arr) { + if (TypeGuard.isString(env)) { + if (env in context.env) return true; + } else if (TypeGuard.isArray(env)) { + const present = env.filter((env) => env in context.env); + if (present.length === env.length) return true; + } else { + const entries = Object.entries(env); + const matches = entries.filter( + ([key, value]) => context.env[key] === value + ); + if (entries.length === matches.length) return true; + } + } + + return false; +} diff --git a/src/utils/is-interactive.ts b/src/utils/is-interactive.ts new file mode 100644 index 0000000..b5f2998 --- /dev/null +++ b/src/utils/is-interactive.ts @@ -0,0 +1,20 @@ +import { Context } from '../definitions'; +import { isCI } from './is-ci'; + +/** + * Returns `true` when a context belongs to an interactive + * environment. + * Ensures that the context interactive property is `true`, + * the stdout is a non-dumb *TTY*, and it's not running in a CI. + */ +export function isInteractive(context: Context): boolean { + if (!context.interactive) return false; + + const stdout = context.stdio[1] as NodeJS.WriteStream; + if (!stdout || !stdout.isTTY) return false; + + if (context.env.TERM === 'dumb') return false; + if (isCI(context)) return false; + + return true; +}