--- /dev/null
+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
-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,
+ });
+ }
+});
<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/>
-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:
-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);
}
});
+++ /dev/null
-Configure launchd
+++ /dev/null
----
-# @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}}'
+++ /dev/null
-<?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>
export default class Attributes {
#homedir?: string;
#platform?: 'darwin' | 'linux';
+ #username?: string;
get homedir(): string {
if (!this.#homedir) {
return this.#platform;
}
+
+ get username(): string {
+ if (!this.#username) {
+ this.#username = os.userInfo().username;
+ }
+
+ return this.#username;
+ }
}
--- /dev/null
+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)!;
+ }
+}
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';
/**
* 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,
ok: 0,
skipped: 0,
};
+
+ this.#tasks = new TaskRegistry();
+ }
+
+ compile(path: string) {
+ return this.#compiler.compile(path);
}
informChanged(message: string) {
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;
this.#currentAspect = aspect;
this.#currentVariables = variables;
- callback();
+ await callback();
} finally {
this.#currentAspect = previousAspect;
this.#currentVariables = previousVariables;
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();
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) || [];
+ }
}
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';
export {command};
export {file};
-export {path};
+export {resource};
export {root};
export {task};
export {template};
export interface Fig {
command: typeof command;
file: typeof file;
- path: typeof path;
+ resource: typeof resource;
root: typeof root;
task: typeof task;
template: typeof template;
/**
* 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) {
// 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,
src?: string;
state: 'directory' | 'file' | 'link' | 'touch';
force?: boolean;
-}) {
+}): Promise<void> {
if (state === 'directory') {
directory(path);
}
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');
+ }
+ }
+ }
}
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);
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);
}
});
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`
// 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`
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`
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`
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, {
export default function expand(path: string) {
if (path.startsWith('~/')) {
return join(homedir(), path.slice(2));
+ } else {
+ return path;
}
-
- return 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';
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();
});
}
}
--- /dev/null
+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();
+ }
+}
-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();
+ }
+ });
+ });
}
+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).
* `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();
}