]> git.wincent.com - wincent.git/commitdiff
refactor: make launchd aspect partly functional
authorGreg Hurrell <greg@hurrell.net>
Mon, 30 Mar 2020 18:50:02 +0000 (20:50 +0200)
committerGreg Hurrell <greg@hurrell.net>
Mon, 30 Mar 2020 18:50:02 +0000 (20:50 +0200)
Doesn't actually mutate anything (that's why it is labeled as
"refactor"), but it will soon.

25 files changed:
aspects/launchd/README.md [new file with mode: 0644]
aspects/launchd/index.ts
aspects/launchd/templates/run.plist.erb
aspects/terminfo/README.md
aspects/terminfo/index.ts
roles/launchd/description [deleted file]
roles/launchd/tasks/main.yml [deleted file]
roles/launchd/templates/run.plist [deleted file]
src/Attributes.ts
src/Compiler.ts [new file with mode: 0644]
src/Fig/Context.ts
src/Fig/TaskRegistry.ts
src/Fig/index.ts
src/Fig/operations/command.ts
src/Fig/operations/file.ts
src/Fig/operations/template.ts
src/Fig/resource.ts [moved from src/Fig/path.ts with 100% similarity]
src/Fig/task.ts
src/__tests__/template-test.ts
src/capture.ts
src/expand.ts
src/main.ts
src/prompt.ts [new file with mode: 0644]
src/spawn.ts
src/template.ts

diff --git a/aspects/launchd/README.md b/aspects/launchd/README.md
new file mode 100644 (file)
index 0000000..dcf0d5c
--- /dev/null
@@ -0,0 +1,6 @@
+Configures launchd to:
+
+- Persist overrides of ridiculously low ulimit values in macOS.
+- Set locale-related environmental variables for the benefit of GUI apps.
+
+See: http://unix.stackexchange.com/questions/108174/how-to-persist-ulimit-settings-in-osx-mavericks
index a0e6a5454c2e4bb3e1483f8c27f4dcb0852ecac1..30be38d59fb5f06832a71625327d24a6576734f1 100644 (file)
@@ -1,59 +1,59 @@
-import {path, template, task} from '../../src/Fig';
+import {resource, template, task} from '../../src/Fig';
 
-task('configure (global) LaunchDaemons', () => {
-  [
+task('configure (global) LaunchDaemons', async () => {
+  const items = [
     {
-      dest: '/Library/LaunchDaemons/limit.maxfiles.plist',
+      path: '/Library/LaunchDaemons/limit.maxfiles.plist',
       variables: {
         arguments: ['limit', 'maxfiles', 65536, 65536],
+        label: 'limit.maxfiles',
       },
     },
     {
-      dest: '/Library/LaunchDaemons/limit.maxproc.plist',
+      path: '/Library/LaunchDaemons/limit.maxproc.plist',
       variables: {
         arguments: ['limit', 'maxproc', 2048, 2048],
+        label: 'limit.maxproc',
       },
     },
-  ].forEach(({dest, variables}) => {
-    template({
+  ];
+
+  for (const {path, variables} of items) {
+    await template({
       group: 'wheel',
       mode: '0644',
       owner: 'root',
-      path: dest,
-      src: path.template('run.plist.erb'),
+      path,
+      src: resource.template('run.plist.erb'),
       variables,
     });
-  });
+  }
 });
 
-task('configure (local) LaunchAgents', () => {});
-
-/*
-# @see http://unix.stackexchange.com/questions/108174/how-to-persist-ulimit-settings-in-osx-mavericks
-- name: launchd | configure (global) LaunchDaemons
-  template: group=wheel
-            mode=0644
-            owner=root
-            dest=/Library/LaunchDaemons/{{ item.label }}.plist
-            src=run.plist
-  loop:
-    - label: limit.maxproc
-      args: ['limit', 'maxproc', 2048, 2048]
-    - label: limit.maxfiles
-      args: ['limit', 'maxfiles', 65536, 65536]
-  loop_control:
-    label: '{{item.label}}'
-  become: !!bool yes
+task('configure (local) LaunchAgents', async () => {
+  const items = [
+    {
+      path: '~/Library/LaunchAgents/setenv.lang.plist',
+      variables: {
+        arguments: ['setenv', 'LANG', 'en_US.UTF-8'],
+        label: 'setenv.lang',
+      },
+    },
+    {
+      path: '~/Library/LaunchAgents/setenv.lc_time.plist',
+      variables: {
+        arguments: ['setenv', 'LC_TIME', 'en_AU.UTF-8'],
+        label: 'setenv.lc_time',
+      },
+    },
+  ];
 
-- name: launchd | configure (local) LaunchAgents
-  template: mode=0644
-            dest=~/Library/LaunchAgents/{{ item.label }}.plist
-            src=run.plist
-  loop:
-    - label: setenv.lang
-      args: ['setenv', 'LANG', 'en_US.UTF-8']
-    - label: setenv.lc_time
-      args: ['setenv', 'LC_TIME', 'en_AU.UTF-8']
-  loop_control:
-    label: '{{item.label}}'
-*/
+  for (const {path, variables} of items) {
+    await template({
+      mode: '0644',
+      path,
+      src: resource.template('run.plist.erb'),
+      variables,
+    });
+  }
+});
index 2b28c3a6cf761b347775bc6ff3885a192beb5341..b9fb0199214693ea527c6a531e0c596091f3a16f 100644 (file)
@@ -3,13 +3,13 @@
 <plist version="1.0">
   <dict>
     <key>Label</key>
-    <string><%= item.label %></string>
+    <string><%= variables.label %></string>
     <key>ProgramArguments</key>
     <array>
       <string>launchctl</string>
-<%- for (const argument in item.arguments) { %>
-      <string><%= arg %></string>
-<%- } %>
+<%- for (const argument of variables.arguments) { -%>
+      <string><%= argument %></string>
+<%- } -%>
     </array>
     <key>RunAtLoad</key>
     <true/>
index 26848a823f743d56357c9d7156da322eeab90afc..99601cc3d3986069f0437dd3a245efae65cfe586 100644 (file)
@@ -1,5 +1,4 @@
-This aspect manages `TERMINFO` files that add escape sequences for italic,
-and overwrite conflicting sequences for standout text.
+This aspect manages `TERMINFO` files that add escape sequences for italic, and overwrite conflicting sequences for standout text.
 
 To check that the terminal does the right thing:
 
index f9699c8f340afc8bc975cc6e95670dcaf515f088..4b1f131320ae496b42bf8e7074262c126113ac18 100644 (file)
@@ -1,14 +1,14 @@
-import {command, file, path, task, variable} from '../../src/Fig';
+import {command, file, resource, task, variable} from '../../src/Fig';
 
-task('create target directory', () => {
-  file({
+task('create target directory', async () => {
+  await file({
     path: variable.string('terminfo_path'),
     state: 'directory',
   });
 });
 
-task('update terminfo files', () => {
-  for (const terminfo of path.files('*.terminfo')) {
-    command('tic', '-o', variable.string('terminfo_path'), terminfo);
+task('update terminfo files', async () => {
+  for (const terminfo of resource.files('*.terminfo')) {
+    await command('tic', '-o', variable.string('terminfo_path'), terminfo);
   }
 });
diff --git a/roles/launchd/description b/roles/launchd/description
deleted file mode 100644 (file)
index 5dc9dcb..0000000
+++ /dev/null
@@ -1 +0,0 @@
-Configure launchd
diff --git a/roles/launchd/tasks/main.yml b/roles/launchd/tasks/main.yml
deleted file mode 100644 (file)
index 800d7d4..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
----
-# @see http://unix.stackexchange.com/questions/108174/how-to-persist-ulimit-settings-in-osx-mavericks
-- name: launchd | configure (global) LaunchDaemons
-  template: group=wheel
-            mode=0644
-            owner=root
-            dest=/Library/LaunchDaemons/{{ item.label }}.plist
-            src=run.plist
-  loop:
-    - label: limit.maxproc
-      args: ['limit', 'maxproc', 2048, 2048]
-    - label: limit.maxfiles
-      args: ['limit', 'maxfiles', 65536, 65536]
-  loop_control:
-    label: '{{item.label}}'
-  become: !!bool yes
-
-- name: launchd | configure (local) LaunchAgents
-  template: mode=0644
-            dest=~/Library/LaunchAgents/{{ item.label }}.plist
-            src=run.plist
-  loop:
-    - label: setenv.lang
-      args: ['setenv', 'LANG', 'en_US.UTF-8']
-    - label: setenv.lc_time
-      args: ['setenv', 'LC_TIME', 'en_AU.UTF-8']
-  loop_control:
-    label: '{{item.label}}'
diff --git a/roles/launchd/templates/run.plist b/roles/launchd/templates/run.plist
deleted file mode 100644 (file)
index 201c59b..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
-<plist version="1.0">
-  <dict>
-    <key>Label</key>
-    <string>{{ item.label }}</string>
-    <key>ProgramArguments</key>
-    <array>
-      <string>launchctl</string>
-{% for arg in item.args %}
-      <string>{{ arg }}</string>
-{% endfor %}
-    </array>
-    <key>RunAtLoad</key>
-    <true/>
-    <key>ServiceIPC</key>
-    <false/>
-  </dict>
-</plist>
index 7667e660a96d6e7c0eb3cd7d0933cc46a7911df7..3de00b08d1700d63e4504b41238d95bd7b35e397 100644 (file)
@@ -6,6 +6,7 @@ import * as os from 'os';
 export default class Attributes {
   #homedir?: string;
   #platform?: 'darwin' | 'linux';
+  #username?: string;
 
   get homedir(): string {
     if (!this.#homedir) {
@@ -30,4 +31,12 @@ export default class Attributes {
 
     return this.#platform;
   }
+
+  get username(): string {
+    if (!this.#username) {
+      this.#username = os.userInfo().username;
+    }
+
+    return this.#username;
+  }
 }
diff --git a/src/Compiler.ts b/src/Compiler.ts
new file mode 100644 (file)
index 0000000..ea8c44d
--- /dev/null
@@ -0,0 +1,37 @@
+import {readFile as readFileAsync} from 'fs';
+import {promisify} from 'util';
+
+import {compile, fill} from './template';
+
+import type {Scope} from './template';
+
+const readFile = promisify(readFileAsync);
+
+/**
+ * Template compiler that manages a cache of compiled templates.
+ */
+export default class Compiler {
+  #compiled: Map<string, {fill: (scope: Scope) => string}>;
+
+  constructor() {
+    this.#compiled = new Map();
+  }
+
+  async compile(path: string): Promise<{fill: (scope: Scope) => string}> {
+    const map = this.#compiled;
+
+    if (!map.has(path)) {
+      const source = await readFile(path, 'utf8');
+
+      const compiled = compile(source);
+
+      map.set(path, {
+        fill(scope) {
+          return fill(compiled, scope);
+        },
+      });
+    }
+
+    return map.get(path)!;
+  }
+}
index c2129a9b96cca23763dfffad633b1aa9026baaaa..c4db9ed81209f019e6ed6be0d0abd77cb9f1558a 100644 (file)
@@ -2,7 +2,10 @@ import * as assert from 'assert';
 
 import Attributes from '../Attributes';
 import ErrorWithMetadata from '../ErrorWithMetadata';
+import prompt from '../prompt';
 import * as status from './status';
+import Compiler from '../Compiler';
+import TaskRegistry from './TaskRegistry';
 
 import type {Metadata} from '../ErrorWithMetadata';
 import type {Aspect} from '../types/Project';
@@ -17,26 +20,21 @@ type Counts = {
 /**
  * Try to keep nasty global state all together in one place.
  *
- * TODO: move global state out of TaskRegisty
- *
  * Global state helps keep our "aspect" DSL as lightweight/implicit as
  * possible.
  */
 class Context {
   #attributes: Attributes;
-
-  // TODO: decide how to deal with "recap"; ansible prints something like this:
-  //
-  // PLAY RECAP
-  // ok=16 changed=7 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0
+  #compiler: Compiler;
   #counts: Counts;
-
   #currentAspect?: Aspect;
-
   #currentVariables?: Variables;
+  #sudoPassphrase?: Promise<string>;
+  #tasks: TaskRegistry;
 
   constructor() {
     this.#attributes = new Attributes();
+    this.#compiler = new Compiler();
 
     this.#counts = {
       changed: 0,
@@ -44,6 +42,12 @@ class Context {
       ok: 0,
       skipped: 0,
     };
+
+    this.#tasks = new TaskRegistry();
+  }
+
+  compile(path: string) {
+    return this.#compiler.compile(path);
   }
 
   informChanged(message: string) {
@@ -90,9 +94,9 @@ class Context {
     status.skipped(message);
   }
 
-  withContext(
+  async withContext(
     {aspect, variables}: {aspect: Aspect; variables: Variables},
-    callback: () => void
+    callback: () => Promise<void>
   ) {
     let previousAspect = this.#currentAspect;
     let previousVariables = this.#currentVariables;
@@ -101,7 +105,7 @@ class Context {
       this.#currentAspect = aspect;
       this.#currentVariables = variables;
 
-      callback();
+      await callback();
     } finally {
       this.#currentAspect = previousAspect;
       this.#currentVariables = previousVariables;
@@ -135,6 +139,23 @@ class Context {
   set currentVariables(variables: Variables) {
     this.#currentVariables = variables;
   }
+
+  get sudoPassphrase(): Promise<string> {
+    if (!this.#sudoPassphrase) {
+      this.#sudoPassphrase = prompt(`Password [will not be echoed]: `, {
+        private: true,
+      });
+    }
+
+    return this.#sudoPassphrase;
+  }
+
+  // TODO: note that I might be going overboard here with private
+  // variables for stuff that I really don't have to worry about getting
+  // meddled with
+  get tasks(): TaskRegistry {
+    return this.#tasks;
+  }
 }
 
 export default new Context();
index 5f0e68b073790b86d5623d221a1e463aacab567c..0812043ac66675d70f15ec4c5e624e0ebb964763 100644 (file)
@@ -1,17 +1,23 @@
 import type {Aspect} from '../types/Project';
 
-type Callback = () => void;
+type Callback = () => Promise<void>;
 
-const callbacks = new Map<Aspect, Array<Callback>>();
+export default class TaskRegistry {
+  #callbacks: Map<Aspect, Array<[Callback, string]>>;
 
-export function register(aspect: Aspect, callback: Callback) {
-  if (!callbacks.has(aspect)) {
-    callbacks.set(aspect, []);
+  constructor() {
+    this.#callbacks = new Map();
   }
 
-  callbacks.get(aspect)!.push(callback);
-}
+  register(aspect: Aspect, callback: Callback, name: string) {
+    if (!this.#callbacks.has(aspect)) {
+      this.#callbacks.set(aspect, []);
+    }
+
+    this.#callbacks.get(aspect)!.push([callback, name]);
+  }
 
-export function get(aspect: Aspect): Array<Callback> {
-  return callbacks.get(aspect) || [];
+  get(aspect: Aspect): Array<[Callback, string]> {
+    return this.#callbacks.get(aspect) || [];
+  }
 }
index f64b5e27dcc071dd0c5d65a54837c6bcba0a72ad..c9025db848ceb24961a055b1c771eff69c499e7a 100644 (file)
@@ -1,6 +1,6 @@
 import {default as command} from './operations/command';
 import {default as file} from './operations/file';
-import * as path from './path';
+import * as resource from './resource';
 import {default as root} from './root';
 import {default as task} from './task';
 import {default as template} from './operations/template';
@@ -8,7 +8,7 @@ import {default as variable} from './variable';
 
 export {command};
 export {file};
-export {path};
+export {resource};
 export {root};
 export {task};
 export {template};
@@ -17,7 +17,7 @@ export {variable};
 export interface Fig {
   command: typeof command;
   file: typeof file;
-  path: typeof path;
+  resource: typeof resource;
   root: typeof root;
   task: typeof task;
   template: typeof template;
index 17f0caa008e2e9f5279960a49d8705a6524cadef..883c5ba2f15cde5cf893e62f9e731cada3092d7d 100644 (file)
@@ -6,11 +6,14 @@ import Context from '../Context';
 /**
  * Implements basic shell expansion (of ~).
  */
-export default function command(executable: string, ...args: Array<string>) {
+export default async function command(
+  executable: string,
+  ...args: Array<string>
+): Promise<void> {
   const description = [executable, ...args].join(' ');
 
   try {
-    spawn(expand(executable), ...args.map(expand));
+    await spawn(expand(executable), ...args.map(expand));
     // TODO: decide whether to log full command here
     Context.informChanged(`command \`${description}\``);
   } catch (error) {
index ce1bcd211eb39a78e4ad1a62723e105e71b5c496..2e17e391da10a0b719b46786ab6879c5b206c08e 100644 (file)
@@ -6,7 +6,7 @@ import Context from '../Context';
 
 // TODO decide whether we want a separate "directory" operation
 // TODO: implement auto-expand of ~
-export default function file({
+export default async function file({
   force,
   mode,
   path,
@@ -18,7 +18,7 @@ export default function file({
   src?: string;
   state: 'directory' | 'file' | 'link' | 'touch';
   force?: boolean;
-}) {
+}): Promise<void> {
   if (state === 'directory') {
     directory(path);
   }
index 29f11b28a8a5cf119d6249b2122f1ebc95c2cdf1..6d7531aca829b9928f6f95586d230373e5e9103a 100644 (file)
@@ -2,27 +2,52 @@ import * as fs from 'fs';
 
 import {log} from '../../console';
 import expand from '../../expand';
+import {compile, fill} from '../../template';
 import Context from '../Context';
 
-export default function template({
-  // force,
+export default async function template({
   group,
   mode,
   owner,
   path,
   src,
-  // sudo,
-  variables,
+  variables = {},
 }: {
   group?: string;
   path: string;
   mode?: string;
   owner?: string;
   src: string;
-  variables?: Variables;
-  // force?: boolean;
-  // sudo?: boolean;
-}) {
-  log.info(`template ${src} -> ${path}`);
-  // TODO expand paths
+  variables: Variables;
+}): Promise<void> {
+  const target = expand(path);
+  log.info(`template ${src} -> ${target}`);
+
+  const filled = (await Context.compile(src)).fill({variables});
+
+  if (owner && owner !== Context.attributes.username) {
+    log.notice(`needs sudo: ${Context.attributes.username} -> ${owner}`);
+    const passphrase = await Context.sudoPassphrase;
+    // obviously not going to log this in real life:
+    log.debug(`got: ${passphrase}`);
+    // chown in node works with numeric uid and gid
+  } else {
+    // open, write, mode
+    // can't chown, i think? without uid and gid
+
+    // TODO extract this somewhere else
+    // need low-level filesystem ops that are consumed by the high-level
+    // user-accessible ops
+    let contents;
+
+    if (fs.existsSync(target)) {
+      contents = fs.readFileSync(target, 'utf8');
+
+      if (contents !== filled) {
+        log.info('change!');
+      } else {
+        log.info('no change');
+      }
+    }
+  }
 }
similarity index 100%
rename from src/Fig/path.ts
rename to src/Fig/resource.ts
index f6d5a2c7c8fc9fe62d0ce54ccc401514864e91f6..f3370342b926218b4faffe076127915c7be87e82 100644 (file)
@@ -1,15 +1,11 @@
 import {relative, sep} from 'path';
 
 import {assertAspect} from '../types/Project';
-import * as TaskRegistry from './TaskRegistry';
 import getCaller from '../getCaller';
-import {default as command} from './operations/command';
-import {default as file} from './operations/file';
-import * as path from './path';
+import Context from './Context';
 import {default as root} from './root';
-import {default as variable} from './variable';
 
-export default function task(name: string, callback: () => void) {
+export default function task(name: string, callback: () => Promise<void>) {
   const caller = getCaller();
 
   const ancestors = relative(root, caller).split(sep);
@@ -24,8 +20,5 @@ export default function task(name: string, callback: () => void) {
   assertAspect(aspect);
   // TODO: use `caller` to make namespaced task name.
 
-  TaskRegistry.register(aspect, callback);
-  // TODO: decide how to make context available to these functions. can either
-  // register it somewhere global, or pass it as a config object (and can use
-  // bind() for that)
+  Context.tasks.register(aspect, callback, name);
 }
index ab117eb41455c3ac329553452ece22ddd3595702..8c26baf5c518bfa19314c774d2f88cfcf0a566e3 100644 (file)
@@ -66,35 +66,39 @@ test('compile() compiles a template containing statements', () => {
 });
 
 test('fill() fills an empty template', () => {
-  expect(fill('')).toBe('');
+  expect(fill(compile(''))).toBe('');
 });
 
 test('fill() fills a template containing only template text', () => {
-  expect(fill('stuff')).toBe('stuff');
+  expect(fill(compile('stuff'))).toBe('stuff');
 });
 
 test('fill() fills a template containing an expression', () => {
-  expect(fill('stuff <%= "here" %>')).toBe('stuff here');
+  expect(fill(compile('stuff <%= "here" %>'))).toBe('stuff here');
 });
 
 test('fill() fills a template that relies on scope', () => {
-  expect(fill('name: <%= name %>', {name: 'Bob'})).toBe('name: Bob');
+  expect(fill(compile('name: <%= name %>'), {name: 'Bob'})).toBe('name: Bob');
 });
 
 test('fill() complains when required scope is missing', () => {
-  expect(() => fill('name: <%= name %>')).toThrow('name is not defined');
+  expect(() => fill(compile('name: <%= name %>'))).toThrow(
+    'name is not defined'
+  );
 });
 
 test('fill() fills a template containing statements', () => {
   expect(
     fill(
-      dedent`
-        first
-        <% if (something === 'that') { %>
-        second
-        <% } %>
-        third
-      `,
+      compile(
+        dedent`
+          first
+          <% if (something === 'that') { %>
+          second
+          <% } %>
+          third
+        `
+      ),
       {something: 'that'}
     )
   ).toBe(dedent`
@@ -108,13 +112,15 @@ test('fill() fills a template containing statements', () => {
   // In practice, you'd use the slurping variants ("<%-", "-%>").
   expect(
     fill(
-      dedent`
-        first
-        <%- if (something === 'that') { -%>
-        second
-        <%- } -%>
-        third
-      `,
+      compile(
+        dedent`
+          first
+          <%- if (something === 'that') { -%>
+          second
+          <%- } -%>
+          third
+        `
+      ),
       {something: 'that'}
     )
   ).toBe(dedent`
@@ -127,13 +133,15 @@ test('fill() fills a template containing statements', () => {
 test('fill() correctly handles indented slurping delimiters', () => {
   expect(
     fill(
-      dedent`
-        #start
-          <%- if (something === 'that') { -%>
-          middle
-          <%- } -%>
-        #end
-      `,
+      compile(
+        dedent`
+          #start
+            <%- if (something === 'that') { -%>
+            middle
+            <%- } -%>
+          #end
+        `
+      ),
       {something: 'that'}
     )
   ).toBe(dedent`
@@ -146,11 +154,13 @@ test('fill() correctly handles indented slurping delimiters', () => {
 test('fill() correctly handles slurping delimiters at edges of template', () => {
   expect(
     fill(
-      dedent`
-        <%- if (something === 'that') { -%>
-        conditional
-        <%- } -%>
-      `,
+      compile(
+        dedent`
+          <%- if (something === 'that') { -%>
+          conditional
+          <%- } -%>
+        `
+      ),
       {something: 'that'}
     )
   ).toBe(dedent`
index 241e0333cc25f97f29576c363e2b205e1c976735..b5eaef8763ffbb4ec558158ee4de872a6a91aa00 100644 (file)
@@ -1,5 +1,6 @@
 import {spawn} from 'child_process';
 
+// TODO decide if we still need this, and we can make it async
 export default async function capture(command: string, ...args: Array<string>) {
   return new Promise((resolve, reject) => {
     const child = spawn(command, args, {
index 71b09af770cb3acfb6682209033ef84b9f2c2654..211b7d1feb75e07271bad642f205d52d036fd95d 100644 (file)
@@ -4,7 +4,7 @@ import {join} from 'path';
 export default function expand(path: string) {
   if (path.startsWith('~/')) {
     return join(homedir(), path.slice(2));
+  } else {
+    return path;
   }
-
-  return path;
 }
index fe4e3d13c0401e10386ff0fa405de575304d465c..fe4f8f685798f369e03b604ccfa4d873fe21fac7 100644 (file)
@@ -4,7 +4,6 @@ import * as path from 'path';
 import ErrorWithMetadata from './ErrorWithMetadata';
 import Context from './Fig/Context';
 import {root} from './Fig';
-import * as TaskRegistry from './Fig/TaskRegistry';
 import {log} from './console';
 import merge from './merge';
 import readAspect from './readAspect';
@@ -84,12 +83,11 @@ async function main() {
 
       log.debug(`variables:\n\n${JSON.stringify(variables, null, 2)}\n`);
 
-      for (const callback of TaskRegistry.get(aspect)) {
-        // TODO: may want to make these async, but will end up polluting
-        // everything with `await` keywords... better to use blocking sync
-        // everywhere I think
-        Context.withContext({aspect, variables}, () => {
-          callback();
+      for (const [callback, name] of Context.tasks.get(aspect)) {
+        log.info(`task: ${name}`);
+
+        await Context.withContext({aspect, variables}, async () => {
+          await callback();
         });
       }
     }
diff --git a/src/prompt.ts b/src/prompt.ts
new file mode 100644 (file)
index 0000000..efa3534
--- /dev/null
@@ -0,0 +1,44 @@
+import * as readline from 'readline';
+import {Writable} from 'stream';
+
+type Options = {
+  private?: boolean;
+};
+
+export default async function prompt(
+  text: string,
+  options: Options = {}
+): Promise<string> {
+  let muted = false;
+
+  // https://stackoverflow.com/a/33500118/2103996
+  const stdout = new Writable({
+    write: (chunk, _encoding, callback) => {
+      if (muted) {
+        process.stdout.write('*'.repeat(chunk.toString().length));
+      } else {
+        process.stdout.write(chunk);
+      }
+      callback();
+    },
+  });
+
+  const rl = readline.createInterface({
+    historySize: 0,
+    input: process.stdin,
+    output: stdout,
+    terminal: true,
+  });
+
+  try {
+    const response = new Promise<string>((resolve) =>
+      rl.question(text, resolve)
+    );
+
+    muted = !!options.private;
+
+    return await response;
+  } finally {
+    rl.close();
+  }
+}
index e9caf2196fa00d18cc27f7be8accc15108f605e3..4d05b5586f983df18cc2e9d6cff434b9d822ff92 100644 (file)
@@ -1,33 +1,43 @@
-import {spawnSync} from 'child_process';
+import * as child_process from 'child_process';
 
 import ErrorWithMetadata from './ErrorWithMetadata';
 
-export default function spawn(command: string, ...args: Array<string>) {
-  const {error, signal, status, stderr, stdout} = spawnSync(command, args, {
-    stdio: ['inherit', 'pipe', 'pipe'],
-  });
-
-  if (error || signal || status) {
-    const description = [command, ...args].join(' ');
+export default async function spawn(
+  command: string,
+  ...args: Array<string>
+): Promise<void> {
+  return new Promise((resolve, reject) => {
+    let stderr = '';
+    let stdout = '';
 
-    let message;
+    function fail(message: string) {
+      const description = [command, ...args].join(' ');
+      const metadata = {stderr, stdout};
 
-    if (error) {
-      message = `command ${description} encountered error: ${error}`;
-    } else if (signal) {
-      message = `command ${description} exited due to signal ${signal}`;
-    } else if (status) {
-      message = `command ${description} exited with status ${status}`;
-    } else {
-      // Will never get here, but need an "else" to keep TypeScript happy.
-      message = `command ${description} failed`;
+      reject(
+        new ErrorWithMetadata(`command ${description} ${message}`, metadata)
+      );
     }
 
-    const metadata = {
-      stderr: stderr.toString(),
-      stdout: stderr.toString(),
-    };
+    const child = child_process.spawn(command, args, {
+      stdio: ['inherit', 'pipe', 'pipe'],
+    });
+
+    child.stderr.on('data', (data) => (stderr += data.toString()));
+    child.stdout.on('data', (data) => (stdout += data.toString()));
 
-    throw new ErrorWithMetadata(message, metadata);
-  }
+    child.on('error', (error) => {
+      fail(`encountered error: ${error}`);
+    });
+
+    child.on('exit', (code, signal) => {
+      if (code) {
+        fail(`exited with status ${code}`);
+      } else if (signal) {
+        fail(`exited due to signal ${signal}`);
+      } else {
+        resolve();
+      }
+    });
+  });
 }
index 17d919ee81b35091a2fdec19efe7eb4528f1d2ee..18fc417da543347c8b7c1f5ddc1d9f9f033499a3 100644 (file)
@@ -1,3 +1,7 @@
+export type Scope = {
+  [property: string]: JSONValue;
+};
+
 /**
  * Returns a "compiled" template (a string containing a function body that can
  * be evaluated to produce the template output).
@@ -35,15 +39,12 @@ export function compile(source: string) {
  * `scope` that provides variables and any other material that maybe needed,
  * producing the final string result.
  */
-export function fill(
-  template: string,
-  scope: {[property: string]: JSONValue} = {}
-) {
+export function fill(compiled: string, scope: Scope = {}) {
   const context = Object.entries(scope).map(
     ([key, value]) => `const ${key} = ${JSON.stringify(value)};\n`
   );
 
-  const sandbox = new Function(context + compile(template));
+  const sandbox = new Function(context + compiled);
 
   return sandbox();
 }