Navigation Menu

Skip to content

Commit

Permalink
feat(cli): opt-in anonymous usage data (#4022)
Browse files Browse the repository at this point in the history
  • Loading branch information
imhoffd committed Jan 8, 2021
1 parent b23b077 commit 3facfb7
Show file tree
Hide file tree
Showing 10 changed files with 483 additions and 41 deletions.
1 change: 1 addition & 0 deletions cli/package.json
Expand Up @@ -50,6 +50,7 @@
"@ionic/utils-terminal": "^2.3.0",
"commander": "^6.0.0",
"debug": "^4.2.0",
"env-paths": "^2.2.0",
"kleur": "^4.1.1",
"native-run": "^1.2.1",
"open": "^7.1.0",
Expand Down
109 changes: 74 additions & 35 deletions cli/src/index.ts
Expand Up @@ -4,14 +4,18 @@ import c from './colors';
import { loadConfig } from './config';
import type { Config } from './definitions';
import { fatal, isFatal } from './errors';
import { receive } from './ipc';
import { logger, output } from './log';
import { telemetryAction } from './telemetry';
import { wrapAction } from './util/cli';
import { emoji as _e } from './util/emoji';

process.on('unhandledRejection', error => {
console.error(c.failure('[fatal]'), error);
});

process.on('message', receive);

export async function run(): Promise<void> {
try {
const config = await loadConfig();
Expand Down Expand Up @@ -54,10 +58,12 @@ export function runProgram(config: Config): void {
'Optional: Directory of your projects built web assets',
)
.action(
wrapAction(async (appName, appId, { webDir }) => {
const { initCommand } = await import('./tasks/init');
await initCommand(config, appName, appId, webDir);
}),
wrapAction(
telemetryAction(config, async (appName, appId, { webDir }) => {
const { initCommand } = await import('./tasks/init');
await initCommand(config, appName, appId, webDir);
}),
),
);

program
Expand All @@ -78,10 +84,12 @@ export function runProgram(config: Config): void {
"Optional: if provided, Podfile.lock won't be deleted and pod install will use --deployment option",
)
.action(
wrapAction(async (platform, { deployment }) => {
const { syncCommand } = await import('./tasks/sync');
await syncCommand(config, platform, deployment);
}),
wrapAction(
telemetryAction(config, async (platform, { deployment }) => {
const { syncCommand } = await import('./tasks/sync');
await syncCommand(config, platform, deployment);
}),
),
);

program
Expand All @@ -96,20 +104,24 @@ export function runProgram(config: Config): void {
"Optional: if provided, Podfile.lock won't be deleted and pod install will use --deployment option",
)
.action(
wrapAction(async (platform, { deployment }) => {
const { updateCommand } = await import('./tasks/update');
await updateCommand(config, platform, deployment);
}),
wrapAction(
telemetryAction(config, async (platform, { deployment }) => {
const { updateCommand } = await import('./tasks/update');
await updateCommand(config, platform, deployment);
}),
),
);

program
.command('copy [platform]')
.description('copies the web app build into the native app')
.action(
wrapAction(async platform => {
const { copyCommand } = await import('./tasks/copy');
await copyCommand(config, platform);
}),
wrapAction(
telemetryAction(config, async platform => {
const { copyCommand } = await import('./tasks/copy');
await copyCommand(config, platform);
}),
),
);

program
Expand All @@ -123,52 +135,79 @@ export function runProgram(config: Config): void {
.option('--target <id>', 'use a specific target')
.option('--no-sync', `do not run ${c.input('sync')}`)
.action(
wrapAction(async (platform, { list, target, sync }) => {
const { runCommand } = await import('./tasks/run');
await runCommand(config, platform, { list, target, sync });
}),
wrapAction(
telemetryAction(config, async (platform, { list, target, sync }) => {
const { runCommand } = await import('./tasks/run');
await runCommand(config, platform, { list, target, sync });
}),
),
);

program
.command('open [platform]')
.description('opens the native project workspace (Xcode for iOS)')
.action(
wrapAction(async platform => {
const { openCommand } = await import('./tasks/open');
await openCommand(config, platform);
}),
wrapAction(
telemetryAction(config, async platform => {
const { openCommand } = await import('./tasks/open');
await openCommand(config, platform);
}),
),
);

program
.command('add [platform]')
.description('add a native platform project')
.action(
wrapAction(async platform => {
const { addCommand } = await import('./tasks/add');
await addCommand(config, platform);
}),
wrapAction(
telemetryAction(config, async platform => {
const { addCommand } = await import('./tasks/add');
await addCommand(config, platform);
}),
),
);

program
.command('ls [platform]')
.description('list installed Cordova and Capacitor plugins')
.action(
wrapAction(async platform => {
const { listCommand } = await import('./tasks/list');
await listCommand(config, platform);
}),
wrapAction(
telemetryAction(config, async platform => {
const { listCommand } = await import('./tasks/list');
await listCommand(config, platform);
}),
),
);

program
.command('doctor [platform]')
.description('checks the current setup for common errors')
.action(
wrapAction(async platform => {
const { doctorCommand } = await import('./tasks/doctor');
await doctorCommand(config, platform);
wrapAction(
telemetryAction(config, async platform => {
const { doctorCommand } = await import('./tasks/doctor');
await doctorCommand(config, platform);
}),
),
);

program
.command('telemetry [on|off]', { hidden: true })
.description('enable or disable telemetry')
.action(
wrapAction(async onOrOff => {
const { telemetryCommand } = await import('./tasks/telemetry');
await telemetryCommand(onOrOff);
}),
);

program
.command('📡', { hidden: true })
.description('IPC receiver command')
.action(() => {
// no-op: IPC messages are received via `process.on('message')`
});

program
.command('plugin:generate', { hidden: true })
.description('start a new Capacitor plugin')
Expand Down
89 changes: 89 additions & 0 deletions cli/src/ipc.ts
@@ -0,0 +1,89 @@
import { open, mkdirp } from '@ionic/utils-fs';
import { fork } from '@ionic/utils-subprocess';
import Debug from 'debug';
import { request } from 'https';
import { resolve } from 'path';

import type { Metric } from './telemetry';
import { ENV_PATHS } from './util/cli';

const debug = Debug('capacitor:ipc');

export interface TelemetryIPCMessage {
type: 'telemetry';
data: Metric<string, unknown>;
}

export type IPCMessage = TelemetryIPCMessage;

/**
* Send an IPC message to a forked process.
*/
export async function send(msg: IPCMessage): Promise<void> {
const dir = ENV_PATHS.log;
await mkdirp(dir);
const logPath = resolve(dir, 'ipc.log');

debug(
'Sending %O IPC message to forked process (logs: %O)',
msg.type,
logPath,
);

const fd = await open(logPath, 'a');
const p = fork(process.argv[1], ['📡'], { stdio: ['ignore', fd, fd, 'ipc'] });

p.send(msg);
p.disconnect();
p.unref();
}

/**
* Receive and handle an IPC message.
*
* Assume minimal context and keep external dependencies to a minimum.
*/
export async function receive(msg: IPCMessage): Promise<void> {
debug('Received %O IPC message', msg.type);

if (msg.type === 'telemetry') {
const now = new Date().toISOString();
const { data } = msg;

// This request is only made if telemetry is on.
const req = request(
{
hostname: 'api.ionicjs.com',
port: 443,
path: '/events/metrics',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
response => {
debug(
'Sent %O metric to events service (status: %O)',
data.name,
response.statusCode,
);

if (response.statusCode !== 204) {
response.on('data', chunk => {
debug(
'Bad response from events service. Request body: %O',
chunk.toString(),
);
});
}
},
);

const body = {
metrics: [data],
sent_at: now,
};

req.end(JSON.stringify(body));
}
}
52 changes: 52 additions & 0 deletions cli/src/sysconfig.ts
@@ -0,0 +1,52 @@
import { readJSON, writeJSON, mkdirp } from '@ionic/utils-fs';
import Debug from 'debug';
import { dirname, resolve } from 'path';

import { ENV_PATHS } from './util/cli';
import { uuidv4 } from './util/uuid';

const debug = Debug('capacitor:sysconfig');

const SYSCONFIG_FILE = 'sysconfig.json';
const SYSCONFIG_PATH = resolve(ENV_PATHS.config, SYSCONFIG_FILE);

export interface SystemConfig {
/**
* A UUID that anonymously identifies this computer.
*/
readonly machine: string;

/**
* Whether telemetry is enabled or not.
*
* If undefined, a choice has not yet been made.
*/
readonly telemetry?: boolean;
}

export async function readConfig(): Promise<SystemConfig> {
debug('Reading from %O', SYSCONFIG_PATH);

try {
return await readJSON(SYSCONFIG_PATH);
} catch (e) {
if (e.code !== 'ENOENT') {
throw e;
}

const sysconfig: SystemConfig = {
machine: uuidv4(),
};

await writeConfig(sysconfig);

return sysconfig;
}
}

export async function writeConfig(sysconfig: SystemConfig): Promise<void> {
debug('Writing to %O', SYSCONFIG_PATH);

await mkdirp(dirname(SYSCONFIG_PATH));
await writeJSON(SYSCONFIG_PATH, sysconfig, { spaces: '\t' });
}
10 changes: 5 additions & 5 deletions cli/src/tasks/init.ts
Expand Up @@ -58,12 +58,12 @@ export async function initCommand(

printNextSteps(config);
} catch (e) {
output.write(
'Usage: npx cap init appName appId\n' +
'Example: npx cap init "My App" "com.example.myapp"\n\n',
);

if (!isFatal(e)) {
output.write(
'Usage: npx cap init appName appId\n' +
'Example: npx cap init "My App" "com.example.myapp"\n\n',
);

fatal(e.stack ?? e);
}

Expand Down

0 comments on commit 3facfb7

Please sign in to comment.