Merge pull request #1076 from balena-io/add-livepush

Add livepush to balena push
This commit is contained in:
CameronDiver 2019-04-23 14:27:54 +01:00 committed by GitHub
commit 69db3c0171
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 626 additions and 16 deletions

View File

@ -1390,18 +1390,23 @@ Docker host TLS key file
## push <applicationOrDevice>
This command can be used to start an image build on the remote balenaCloud build
servers, or on a local-mode balena device.
This command can be used to start a build on the remote balena cloud builders,
or a local mode balena device.
When building on the balenaCloud servers, the given source directory will be
sent to the remote server. This can be used as a drop-in replacement for the
"git push" deployment method.
When building on a local-mode device, the given source directory will be
When building on a local mode device, the given source directory will be
built on the device, and the resulting containers will be run on the device.
Logs will be streamed back from the device as part of the same invocation.
The web dashboard can be used to switch a device to local mode:
https://www.balena.io/docs/learn/develop/local-mode/
Note that local mode requires a supervisor version of at least v7.21.0.
It is also possible to run a push to a local mode device in live mode.
This will watch for changes in the source directory and perform an
in-place build in the running containers [BETA].
The --registry-secrets option specifies a JSON or YAML file containing private
Docker registry usernames and passwords to be used when pulling base images.
@ -1445,6 +1450,17 @@ Don't use cache when building this project
Path to a local YAML or JSON file containing Docker registry passwords used to pull base images
#### --live, -l
Note this feature is in beta.
Start a live session with the containers pushed to a local-mode device.
The project source folder is watched for filesystem events, and changes
to files and folders are automatically synchronized to the running
containers. The synchronisation is only in one direction, from this machine to
the device, and changes made on the device itself may be overwritten.
This feature requires a device running supervisor version v9.7.0 or greater.
# Settings
## settings

View File

@ -106,6 +106,7 @@ export const push: CommandDefinition<
emulated: boolean;
nocache: boolean;
'registry-secrets': string;
live: boolean;
}
> = {
signature: 'push <applicationOrDevice>',
@ -113,18 +114,23 @@ export const push: CommandDefinition<
description:
'Start a remote build on the balena cloud build servers or a local mode device',
help: stripIndent`
This command can be used to start an image build on the remote balenaCloud build
servers, or on a local-mode balena device.
This command can be used to start a build on the remote balena cloud builders,
or a local mode balena device.
When building on the balenaCloud servers, the given source directory will be
sent to the remote server. This can be used as a drop-in replacement for the
"git push" deployment method.
When building on a local-mode device, the given source directory will be
When building on a local mode device, the given source directory will be
built on the device, and the resulting containers will be run on the device.
Logs will be streamed back from the device as part of the same invocation.
The web dashboard can be used to switch a device to local mode:
https://www.balena.io/docs/learn/develop/local-mode/
Note that local mode requires a supervisor version of at least v7.21.0.
It is also possible to run a push to a local mode device in live mode.
This will watch for changes in the source directory and perform an
in-place build in the running containers [BETA].
${registrySecretsHelp.split('\n').join('\n\t\t')}
@ -165,6 +171,20 @@ export const push: CommandDefinition<
description: stripIndent`
Path to a local YAML or JSON file containing Docker registry passwords used to pull base images`,
},
{
signature: 'live',
alias: 'l',
boolean: true,
description: stripIndent`
Note this feature is in beta.
Start a live session with the containers pushed to a local-mode device.
The project source folder is watched for filesystem events, and changes
to files and folders are automatically synchronized to the running
containers. The synchronisation is only in one direction, from this machine to
the device, and changes made on the device itself may be overwritten.
This feature requires a device running supervisor version v9.7.0 or greater.`,
},
],
async action(params, options, done) {
const sdk = (await import('balena-sdk')).fromSharedOptions();
@ -194,6 +214,13 @@ export const push: CommandDefinition<
const buildTarget = getBuildTarget(appOrDevice);
switch (buildTarget) {
case BuildTarget.Cloud:
// Ensure that the live argument has not been passed to a cloud build
if (options.live) {
exitWithExpectedError(
'The --live flag is only valid when pushing to a local device.',
);
}
const app = appOrDevice;
await exitIfNotLoggedIn();
await Bluebird.join(
@ -229,6 +256,7 @@ export const push: CommandDefinition<
deviceHost: device,
registrySecrets,
nocache: options.nocache || false,
live: options.live || false,
}),
)
.catch(BuildError, e => {

View File

@ -15,6 +15,7 @@
* limitations under the License.
*/
import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import * as request from 'request';
import * as Stream from 'stream';
@ -33,6 +34,29 @@ export interface DeviceInfo {
arch: string;
}
export interface Status {
appState: 'applied' | 'applying';
overallDownloadProgress: null | number;
containers: Array<{
status: string;
serviceName: string;
appId: number;
imageId: number;
serviceId: number;
containerId: string;
createdAt: string;
}>;
images: Array<{
name: string;
appId: number;
serviceName: string;
imageId: number;
dockerImageId: string;
status: string;
downloadProgress: null | number;
}>;
}
const deviceEndpoints = {
setTargetState: 'v2/local/target-state',
getTargetState: 'v2/local/target-state',
@ -40,6 +64,7 @@ const deviceEndpoints = {
logs: 'v2/local/logs',
ping: 'ping',
version: 'v2/version',
status: 'v2/state/status',
};
export class DeviceAPI {
@ -126,6 +151,23 @@ export class DeviceAPI {
});
}
public getStatus(): Promise<Status> {
const url = this.getUrlForAction('status');
return DeviceAPI.promisifiedRequest(request.get, {
url,
json: true,
}).then(body => {
if (body.status !== 'success') {
throw new ApiErrors.DeviceAPIError(
'Non-successful response from supervisor status endpoint',
);
}
return _.omit(body, 'status') as Status;
});
}
public getLogStream(): Bluebird<Stream.Readable> {
const url = this.getUrlForAction('logs');
@ -160,8 +202,6 @@ export class DeviceAPI {
opts: T,
logger?: Logger,
): Promise<any> {
const _ = await import('lodash');
interface ObjectWithUrl {
url?: string;
}

View File

@ -30,8 +30,9 @@ import { Readable } from 'stream';
import { makeBuildTasks } from '../compose_ts';
import Logger = require('../logger');
import { DeviceInfo } from './api';
import { DeviceAPI, DeviceInfo } from './api';
import * as LocalPushErrors from './errors';
import LivepushManager from './live';
import { displayBuildLog } from './logs';
// Define the logger here so the debug output
@ -44,6 +45,7 @@ export interface DeviceDeployOptions {
devicePort?: number;
registrySecrets: RegistrySecrets;
nocache: boolean;
live: boolean;
}
async function checkSource(source: string): Promise<boolean> {
@ -55,7 +57,6 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
const { loadProject, tarDirectory } = await import('../compose');
const { exitWithExpectedError } = await import('../patterns');
const { DeviceAPI } = await import('./api');
const { displayDeviceLogs } = await import('./logs');
if (!(await checkSource(opts.source))) {
@ -66,6 +67,7 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
// First check that we can access the device with a ping
try {
globalLogger.logDebug('Checking we can access device');
await api.ping();
} catch (e) {
exitWithExpectedError(
@ -82,9 +84,15 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
try {
const version = await api.getVersion();
globalLogger.logDebug(`Checking device version: ${version}`);
if (!semver.satisfies(version, '>=7.21.4')) {
exitWithExpectedError(versionError);
}
if (opts.live && !semver.satisfies(version, '>=9.7.0')) {
exitWithExpectedError(
new Error('Using livepush requires a supervisor >= v9.7.0'),
);
}
} catch {
exitWithExpectedError(versionError);
}
@ -104,13 +112,18 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
// Try to detect the device information
const deviceInfo = await api.getDeviceInformation();
await performBuilds(
let buildLogs: Dictionary<string> | undefined;
if (opts.live) {
buildLogs = {};
}
const buildTasks = await performBuilds(
project.composition,
tarStream,
docker,
deviceInfo,
globalLogger,
opts,
buildLogs,
);
globalLogger.logDebug('Setting device state...');
@ -133,7 +146,29 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
// Now all we need to do is stream back the logs
const logStream = await api.getLogStream();
await displayDeviceLogs(logStream, globalLogger);
// Now that we've set the target state, the device will do it's thing
// so we can either just display the logs, or start a livepush session
// (whilst also display logs)
if (opts.live) {
const livepush = new LivepushManager({
api,
buildContext: opts.source,
buildTasks,
docker,
logger: globalLogger,
composition: project.composition,
buildLogs: buildLogs!,
deployOpts: opts,
});
globalLogger.logLivepush('Watching for file changes...');
await Promise.all([
livepush.init(),
displayDeviceLogs(logStream, globalLogger),
]);
} else {
await displayDeviceLogs(logStream, globalLogger);
}
}
function connectToDocker(host: string, port: number): Docker {
@ -151,7 +186,8 @@ export async function performBuilds(
deviceInfo: DeviceInfo,
logger: Logger,
opts: DeviceDeployOptions,
): Promise<void> {
buildLogs?: Dictionary<string>,
): Promise<BuildTask[]> {
const multibuild = await import('resin-multibuild');
const buildTasks = await makeBuildTasks(
@ -165,7 +201,7 @@ export async function performBuilds(
await assignDockerBuildOpts(docker, buildTasks, opts);
logger.logDebug('Starting builds...');
await assignOutputHandlers(buildTasks, logger);
await assignOutputHandlers(buildTasks, logger, buildLogs);
const localImages = await multibuild.performBuilds(buildTasks, docker);
// Check for failures
@ -184,9 +220,59 @@ export async function performBuilds(
await image.remove({ force: true });
}
});
return buildTasks;
}
function assignOutputHandlers(buildTasks: BuildTask[], logger: Logger) {
// Rebuild a single container, execute it on device, and
// return the build logs
export async function rebuildSingleTask(
serviceName: string,
docker: Docker,
logger: Logger,
deviceInfo: DeviceInfo,
composition: Composition,
source: string,
opts: DeviceDeployOptions,
): Promise<string> {
const { tarDirectory } = await import('../compose');
const multibuild = await import('resin-multibuild');
// First we run the build task, to get the new image id
const buildLogs: Dictionary<string> = {};
const tarStream = await tarDirectory(source);
const task = _.find(
await makeBuildTasks(composition, tarStream, deviceInfo, logger),
{ serviceName },
);
if (task == null) {
throw new Error(`Could not find build task for service ${serviceName}`);
}
await assignDockerBuildOpts(docker, [task], opts);
await assignOutputHandlers([task], logger, buildLogs);
const [localImage] = await multibuild.performBuilds([task], docker);
if (!localImage.successful) {
throw new LocalPushErrors.BuildError([
{
error: localImage.error!,
serviceName,
},
]);
}
return buildLogs[task.serviceName];
}
function assignOutputHandlers(
buildTasks: BuildTask[],
logger: Logger,
buildLogs?: Dictionary<string>,
) {
_.each(buildTasks, task => {
if (task.external) {
task.progressHook = progressObj => {
@ -196,6 +282,9 @@ function assignOutputHandlers(buildTasks: BuildTask[], logger: Logger) {
);
};
} else {
if (buildLogs) {
buildLogs[task.serviceName] = '';
}
task.streamHook = stream => {
stream.on('data', (buf: Buffer) => {
const str = _.trimEnd(buf.toString());
@ -204,6 +293,12 @@ function assignOutputHandlers(buildTasks: BuildTask[], logger: Logger) {
{ serviceName: task.serviceName, message: str },
logger,
);
if (buildLogs) {
buildLogs[task.serviceName] = `${
buildLogs[task.serviceName]
}\n${str}`;
}
}
});
};
@ -254,7 +349,7 @@ function generateImageName(serviceName: string): string {
return `local_image_${serviceName}:latest`;
}
function generateTargetState(
export function generateTargetState(
currentTargetState: any,
composition: Composition,
): any {

View File

@ -22,6 +22,15 @@ export class BuildError extends TypedError {
});
return str;
}
public getServiceError(serviceName: string): string {
const failure = _.find(this.failures, f => f.serviceName === serviceName);
if (failure == null) {
return 'Unknown build failure';
}
return failure.error.message;
}
}
export class DeviceAPIError extends TypedError {}

409
lib/utils/device/live.ts Normal file
View File

@ -0,0 +1,409 @@
import * as Bluebird from 'bluebird';
import * as chokidar from 'chokidar';
import * as Dockerode from 'dockerode';
import Livepush from 'livepush';
import * as _ from 'lodash';
import * as path from 'path';
import { Composition } from 'resin-compose-parse';
import { BuildTask } from 'resin-multibuild';
import Logger = require('../logger');
import DeviceAPI, { DeviceInfo, Status } from './api';
import {
DeviceDeployOptions,
generateTargetState,
rebuildSingleTask,
} from './deploy';
import { BuildError } from './errors';
// How often do we want to check the device state
// engine has settled (delay in ms)
const DEVICE_STATUS_SETTLE_CHECK_INTERVAL = 1000;
interface MonitoredContainer {
context: string;
livepush: Livepush;
monitor: chokidar.FSWatcher;
containerId: string;
}
interface ContextEvent {
type: 'add' | 'change' | 'unlink';
filename: string;
serviceName: string;
}
type BuildLogs = Dictionary<string>;
type StageImageIDs = Dictionary<string[]>;
export interface LivepushOpts {
buildContext: string;
composition: Composition;
buildTasks: BuildTask[];
docker: Dockerode;
api: DeviceAPI;
logger: Logger;
buildLogs: BuildLogs;
deployOpts: DeviceDeployOptions;
}
export class LivepushManager {
private lastDeviceStatus: Status | null = null;
private containers: Dictionary<MonitoredContainer> = {};
private dockerfilePaths: Dictionary<string[]> = {};
private deviceInfo: DeviceInfo;
private deployOpts: DeviceDeployOptions;
private buildContext: string;
private composition: Composition;
private buildTasks: BuildTask[];
private docker: Dockerode;
private api: DeviceAPI;
private logger: Logger;
private imageIds: StageImageIDs;
public constructor(opts: LivepushOpts) {
this.buildContext = opts.buildContext;
this.composition = opts.composition;
this.buildTasks = opts.buildTasks;
this.docker = opts.docker;
this.api = opts.api;
this.logger = opts.logger;
this.deployOpts = opts.deployOpts;
this.imageIds = LivepushManager.getMultistageImageIDs(opts.buildLogs);
}
public async init(): Promise<void> {
this.deviceInfo = await this.api.getDeviceInformation();
this.logger.logLivepush('Waiting for device state to settle...');
// The first thing we need to do is let the state 'settle',
// so that all of the containers are running and ready to
// be livepush'd into
await this.awaitDeviceStateSettle();
// Split the composition into a load of differents paths which we can
// create livepush instances for
for (const serviceName of _.keys(this.composition.services)) {
const service = this.composition.services[serviceName];
const buildTask = _.find(this.buildTasks, { serviceName });
if (buildTask == null) {
throw new Error(
`Could not find a build task for service: ${serviceName}`,
);
}
// We only care about builds
if (service.build != null) {
const context = path.join(this.buildContext, service.build.context);
const dockerfile = buildTask.dockerfile;
if (dockerfile == null) {
throw new Error(
`Could not detect dockerfile for service: ${serviceName}`,
);
}
if (buildTask.dockerfilePath == null) {
// this is a bit of a hack as resin-bundle-resolve
// does not always export the dockerfilePath, this
// only happens when the dockerfile path is
// specified differently - this should be patched
// in resin-bundle-resolve
this.dockerfilePaths[
buildTask.serviceName
] = this.getDockerfilePathFromTask(buildTask);
} else {
this.dockerfilePaths[buildTask.serviceName] = [
buildTask.dockerfilePath,
];
}
// Find the containerId from the device state
const container = _.find(this.lastDeviceStatus!.containers, {
serviceName,
});
if (container == null) {
throw new Error(
`Could not find a container on device for service: ${serviceName}`,
);
}
const log = (msg: string) => {
this.logger.logLivepush(`[service ${serviceName}] ${msg}`);
};
const livepush = await Livepush.init(
dockerfile,
context,
container.containerId,
this.imageIds[serviceName],
this.docker,
);
livepush.on('commandExecute', command =>
log(`Executing command: \`${command.command}\``),
);
livepush.on('commandOutput', output =>
log(` ${output.output.data.toString()}`),
);
// TODO: Memoize this for containers which share a context
const monitor = chokidar.watch('.', {
cwd: context,
ignoreInitial: true,
});
monitor.on('add', (changedPath: string) =>
this.handleFSEvent({
filename: changedPath,
type: 'add',
serviceName,
}),
);
monitor.on('change', (changedPath: string) =>
this.handleFSEvent({
filename: changedPath,
type: 'change',
serviceName,
}),
);
monitor.on('unlink', (changedPath: string) =>
this.handleFSEvent({
filename: changedPath,
type: 'unlink',
serviceName,
}),
);
this.containers[serviceName] = {
livepush,
context,
monitor,
containerId: container.containerId,
};
}
}
// Setup cleanup handlers for the device
// This is necessary because the `exit-hook` module is used by several
// dependencies, and will exit without calling the following handler.
// Once https://github.com/balena-io/balena-cli/issues/867 has been solved,
// we are free to (and definitely should) remove the below line
process.removeAllListeners('SIGINT');
process.on('SIGINT', async () => {
this.logger.logLivepush('Cleaning up device...');
await Promise.all(
_.map(this.containers, container => {
container.livepush.cleanupIntermediateContainers();
}),
);
process.exit(0);
});
}
private static getMultistageImageIDs(buildLogs: BuildLogs): StageImageIDs {
const stageIds: StageImageIDs = {};
_.each(buildLogs, (log, serviceName) => {
stageIds[serviceName] = [];
const lines = log.split(/\r?\n/);
let lastArrowMessage: string | undefined;
for (const line of lines) {
// If this was a from line, take the last found
// image id and save it
if (
/step \d+(?:\/\d+)?\s*:\s*FROM/i.test(line) &&
lastArrowMessage != null
) {
stageIds[serviceName].push(lastArrowMessage);
} else {
const msg = LivepushManager.extractDockerArrowMessage(line);
if (msg != null) {
lastArrowMessage = msg;
}
}
}
});
return stageIds;
}
private async awaitDeviceStateSettle(): Promise<void> {
// Cache the state to avoid unnecessary cals
this.lastDeviceStatus = await this.api.getStatus();
if (this.lastDeviceStatus.appState === 'applied') {
return;
}
this.logger.logDebug(
`Device state not settled, retrying in ${DEVICE_STATUS_SETTLE_CHECK_INTERVAL}ms`,
);
await Bluebird.delay(DEVICE_STATUS_SETTLE_CHECK_INTERVAL);
await this.awaitDeviceStateSettle();
}
private async handleFSEvent(fsEvent: ContextEvent): Promise<void> {
this.logger.logDebug(
`Got a filesystem event for service: ${
fsEvent.serviceName
}. Event: ${JSON.stringify(fsEvent)}`,
);
// First we detect if the file changed is the Dockerfile
// used to build the service
if (
_.some(
this.dockerfilePaths[fsEvent.serviceName],
name => name === fsEvent.filename,
)
) {
if (fsEvent.type !== 'change') {
throw new Error(`Deletion or addition of Dockerfiles not supported`);
}
this.logger.logLivepush(
`Detected Dockerfile change, performing full rebuild of service ${
fsEvent.serviceName
}`,
);
await this.handleServiceRebuild(fsEvent.serviceName);
return;
}
let updates: string[] = [];
let deletes: string[] = [];
switch (fsEvent.type) {
case 'add':
case 'change':
updates = [fsEvent.filename];
break;
case 'unlink':
deletes = [fsEvent.filename];
break;
default:
throw new Error(`Unknown event: ${fsEvent.type}`);
}
// Work out if we need to perform any changes on this container
const livepush = this.containers[fsEvent.serviceName].livepush;
this.logger.logLivepush(
`Detected changes for container ${fsEvent.serviceName}, updating...`,
);
await livepush.performLivepush(updates, deletes);
}
private async handleServiceRebuild(serviceName: string): Promise<void> {
try {
const buildTask = _.find(this.buildTasks, { serviceName });
if (buildTask == null) {
throw new Error(
`Could not find a build task for service ${serviceName}`,
);
}
let buildLog: string;
try {
buildLog = await rebuildSingleTask(
serviceName,
this.docker,
this.logger,
this.deviceInfo,
this.composition,
this.buildContext,
this.deployOpts,
);
} catch (e) {
if (!(e instanceof BuildError)) {
throw e;
}
this.logger.logError(
`Rebuild of service ${serviceName} failed!\n Error: ${e.getServiceError(
serviceName,
)}`,
);
return;
}
// TODO: The code below is quite roundabout, and instead
// we'd prefer just to call a supervisor endpoint which
// recreates a container, but that doesn't exist yet
// First we request the current target state
const currentState = await this.api.getTargetState();
// Then we generate a target state without the service
// we rebuilt
const comp = _.cloneDeep(this.composition);
delete comp.services[serviceName];
const intermediateState = generateTargetState(currentState, comp);
await this.api.setTargetState(intermediateState);
// Now we wait for the device state to settle
await this.awaitDeviceStateSettle();
// And re-set the target state
await this.api.setTargetState(
generateTargetState(currentState, this.composition),
);
await this.awaitDeviceStateSettle();
const instance = this.containers[serviceName];
// Get the new container
const container = _.find(this.lastDeviceStatus!.containers, {
serviceName,
});
if (container == null) {
throw new Error(
`Could not find new container for service ${serviceName}`,
);
}
const buildLogs: Dictionary<string> = {};
buildLogs[serviceName] = buildLog;
const stageImages = LivepushManager.getMultistageImageIDs(buildLogs);
instance.livepush = await Livepush.init(
buildTask.dockerfile!,
buildTask.context!,
container.containerId,
stageImages[serviceName],
this.docker,
);
} catch (e) {
this.logger.logError(`There was an error rebuilding the service: ${e}`);
}
}
private static extractDockerArrowMessage(
outputLine: string,
): string | undefined {
const arrowTest = /^.*\s*-+>\s*(.+)/i;
const match = arrowTest.exec(outputLine);
if (match != null) {
return match[1];
}
}
private getDockerfilePathFromTask(task: BuildTask): string[] {
switch (task.projectType) {
case 'Standard Dockerfile':
return ['Dockerfile'];
case 'Dockerfile.template':
return ['Dockerfile.template'];
case 'Architecture-specific Dockerfile':
return [
`Dockerfile.${this.deviceInfo.arch}`,
`Dockerfile.${this.deviceInfo.deviceType}`,
];
default:
return [];
}
}
}
export default LivepushManager;

View File

@ -28,6 +28,7 @@ class Logger {
warn: NodeJS.ReadWriteStream;
error: NodeJS.ReadWriteStream;
logs: NodeJS.ReadWriteStream;
livepush: NodeJS.ReadWriteStream;
};
public formatMessage: (name: string, message: string) => string;
@ -41,6 +42,7 @@ class Logger {
logger.addPrefix('warn', chalk.yellow('[Warn]'));
logger.addPrefix('error', chalk.red('[Error]'));
logger.addPrefix('logs', chalk.green('[Logs]'));
logger.addPrefix('live', chalk.yellow('[Live]'));
this.streams = {
build: logger.createLogStream('build'),
@ -50,6 +52,7 @@ class Logger {
warn: logger.createLogStream('warn'),
error: logger.createLogStream('error'),
logs: logger.createLogStream('logs'),
livepush: logger.createLogStream('live'),
};
_.forEach(this.streams, function(stream, key) {
@ -88,6 +91,10 @@ class Logger {
public logLogs(msg: string) {
return this.streams.logs.write(msg + eol);
}
public logLivepush(msg: string) {
return this.streams.livepush.write(msg + eol);
}
}
export = Logger;

View File

@ -65,6 +65,7 @@
"devDependencies": {
"@types/archiver": "2.1.2",
"@types/bluebird": "3.5.21",
"@types/chokidar": "^1.7.5",
"@types/common-tags": "1.4.0",
"@types/dockerode": "2.5.5",
"@types/es6-promise": "0.0.32",
@ -119,6 +120,7 @@
"body-parser": "^1.14.1",
"capitano": "^1.9.0",
"chalk": "^2.3.0",
"chokidar": "^2.0.4",
"cli-truncate": "^1.1.0",
"coffeescript": "^1.12.6",
"color-hash": "^1.0.3",
@ -143,6 +145,7 @@
"is-root": "^1.0.0",
"js-yaml": "^3.10.0",
"klaw": "^3.0.0",
"livepush": "^1.1.3",
"lodash": "^4.17.4",
"minimatch": "^3.0.4",
"mixpanel": "^0.10.1",

3
typings/global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
interface Dictionary<T> {
[key: string]: T;
}