@chronicle.app/google 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/GoogleApi.d.ts +44 -0
- package/dist/GoogleApi.js +171 -0
- package/dist/GoogleOAuthProvider.d.ts +26 -0
- package/dist/GoogleOAuthProvider.js +47 -0
- package/dist/account.d.ts +9 -0
- package/dist/account.js +14 -0
- package/dist/contacts.d.ts +59 -0
- package/dist/contacts.js +119 -0
- package/dist/gcloud.d.ts +49 -0
- package/dist/gcloud.js +110 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +12 -0
- package/dist/services.d.ts +30 -0
- package/dist/services.js +28 -0
- package/dist/setup.d.ts +20 -0
- package/dist/setup.js +416 -0
- package/package.json +58 -4
package/dist/gcloud.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
|
+
/** A gcloud command that failed, with what it said. */
|
|
4
|
+
export class GcloudError extends Error {
|
|
5
|
+
args;
|
|
6
|
+
stderr;
|
|
7
|
+
/** gcloud's error, unwrapped into one line, without its `ERROR: (gcloud.…)` prefix. */
|
|
8
|
+
said;
|
|
9
|
+
constructor(args, stderr) {
|
|
10
|
+
const said = gcloudSaid(stderr);
|
|
11
|
+
super(`gcloud ${args.slice(0, 2).join(' ')} failed: ${said}`);
|
|
12
|
+
this.args = args;
|
|
13
|
+
this.stderr = stderr;
|
|
14
|
+
this.name = 'GcloudError';
|
|
15
|
+
this.said = said;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The error in gcloud's output: from its `ERROR:` line to the next blank
|
|
20
|
+
* line, which gcloud wraps at 80 columns, as one line. Anything else it
|
|
21
|
+
* printed (update notices) is left out.
|
|
22
|
+
*/
|
|
23
|
+
export function gcloudSaid(stderr) {
|
|
24
|
+
const lines = stderr.split('\n');
|
|
25
|
+
const start = lines.findIndex(line => line.startsWith('ERROR:'));
|
|
26
|
+
const from = start === -1 ? lines.filter(line => line.trim()).slice(-1) : lines.slice(start);
|
|
27
|
+
const end = from.findIndex(line => !line.trim());
|
|
28
|
+
return ((end === -1 ? from : from.slice(0, end))
|
|
29
|
+
.map(line => line.trim())
|
|
30
|
+
.join(' ')
|
|
31
|
+
.replace(/^ERROR:\s*(\(gcloud[^)]*\)\s*)?/, '')
|
|
32
|
+
.trim() || 'no output');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* gcloud with its own config directory (`CLOUDSDK_CONFIG`), so the account
|
|
36
|
+
* and project Chronicle sets up never touch a person's everyday gcloud: its
|
|
37
|
+
* accounts, active project, and application default credentials stay as
|
|
38
|
+
* they were. Commands run with argument arrays, never through a shell.
|
|
39
|
+
*/
|
|
40
|
+
export class CommandLineGcloud {
|
|
41
|
+
configDir;
|
|
42
|
+
bin;
|
|
43
|
+
constructor(configDir, bin = 'gcloud') {
|
|
44
|
+
this.configDir = configDir;
|
|
45
|
+
this.bin = bin;
|
|
46
|
+
}
|
|
47
|
+
async available() {
|
|
48
|
+
try {
|
|
49
|
+
await this.run(['--version']);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async account() {
|
|
57
|
+
// A gcloud that can't list accounts has none we can use.
|
|
58
|
+
const out = await this.run([
|
|
59
|
+
'auth',
|
|
60
|
+
'list',
|
|
61
|
+
'--filter=status:ACTIVE',
|
|
62
|
+
'--format=value(account)',
|
|
63
|
+
]).catch(() => '');
|
|
64
|
+
return out.trim().split('\n')[0] || undefined;
|
|
65
|
+
}
|
|
66
|
+
async login() {
|
|
67
|
+
await this.run(['auth', 'login', '--brief'], { interactive: true });
|
|
68
|
+
}
|
|
69
|
+
async createProject(projectId, name) {
|
|
70
|
+
await this.run(['projects', 'create', projectId, `--name=${name}`]);
|
|
71
|
+
}
|
|
72
|
+
async listProjects() {
|
|
73
|
+
const out = await this.run(['projects', 'list', '--format=value(projectId)']);
|
|
74
|
+
return out.split('\n').filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
async enableApis(projectId, apis) {
|
|
77
|
+
await this.run(['services', 'enable', ...apis, `--project=${projectId}`]);
|
|
78
|
+
}
|
|
79
|
+
async signOut() {
|
|
80
|
+
// Revoking tells Google the token is done; removing the directory takes
|
|
81
|
+
// the rest. A failed revoke (already signed out, offline) still removes it.
|
|
82
|
+
await this.run(['auth', 'revoke', '--all']).catch(() => { });
|
|
83
|
+
await rm(this.configDir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
run(args, { interactive = false } = {}) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const child = spawn(this.bin, args, {
|
|
88
|
+
env: {
|
|
89
|
+
...process.env,
|
|
90
|
+
CLOUDSDK_CONFIG: this.configDir,
|
|
91
|
+
// No "updates are available" notice in the middle of setup.
|
|
92
|
+
CLOUDSDK_COMPONENT_MANAGER_DISABLE_UPDATE_CHECK: '1',
|
|
93
|
+
// Fail instead of stopping at a question nobody sees.
|
|
94
|
+
...(!interactive && { CLOUDSDK_CORE_DISABLE_PROMPTS: '1' }),
|
|
95
|
+
},
|
|
96
|
+
stdio: interactive ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
|
97
|
+
});
|
|
98
|
+
let stdout = '';
|
|
99
|
+
let stderr = '';
|
|
100
|
+
child.stdout?.on('data', chunk => {
|
|
101
|
+
stdout += chunk;
|
|
102
|
+
});
|
|
103
|
+
child.stderr?.on('data', chunk => {
|
|
104
|
+
stderr += chunk;
|
|
105
|
+
});
|
|
106
|
+
child.on('error', error => reject(error));
|
|
107
|
+
child.on('close', code => code === 0 ? resolve(stdout) : reject(new GcloudError(args, stderr)));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { GoogleOAuthProvider } from './GoogleOAuthProvider.js';
|
|
2
|
+
export { GoogleApi, googleAccountOptions, type GoogleApiOptions } from './GoogleApi.js';
|
|
3
|
+
export { googleAccount } from './account.js';
|
|
4
|
+
export { GOOGLE_SERVICES, isGoogleService, type GoogleService } from './services.js';
|
|
5
|
+
export { setupGoogleClient, type SetupOptions } from './setup.js';
|
|
6
|
+
export { CommandLineGcloud, GcloudError, type Gcloud } from './gcloud.js';
|
|
7
|
+
export { ContactDirectory, contactIdentities, contactOptions, type ContactLinks, } from './contacts.js';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { OAuthProviderRegistry } from '@chronicle.app/auth';
|
|
2
|
+
import { GoogleOAuthProvider } from './GoogleOAuthProvider.js';
|
|
3
|
+
// Every Google source imports this package, so `chronicle auth login google`
|
|
4
|
+
// finds the provider once any of them is installed.
|
|
5
|
+
OAuthProviderRegistry.register(GoogleOAuthProvider);
|
|
6
|
+
export { GoogleOAuthProvider } from './GoogleOAuthProvider.js';
|
|
7
|
+
export { GoogleApi, googleAccountOptions } from './GoogleApi.js';
|
|
8
|
+
export { googleAccount } from './account.js';
|
|
9
|
+
export { GOOGLE_SERVICES, isGoogleService } from './services.js';
|
|
10
|
+
export { setupGoogleClient } from './setup.js';
|
|
11
|
+
export { CommandLineGcloud, GcloudError } from './gcloud.js';
|
|
12
|
+
export { ContactDirectory, contactIdentities, contactOptions, } from './contacts.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Google services Chronicle sources read, by the name a person passes to
|
|
3
|
+
* `chronicle auth login google --add`: the read-only scope each needs and
|
|
4
|
+
* the API that has to be on in the person's Google Cloud project.
|
|
5
|
+
*/
|
|
6
|
+
export declare const GOOGLE_SERVICES: {
|
|
7
|
+
readonly gmail: {
|
|
8
|
+
readonly label: "Gmail";
|
|
9
|
+
readonly scopes: readonly ["https://www.googleapis.com/auth/gmail.readonly"];
|
|
10
|
+
readonly api: "gmail.googleapis.com";
|
|
11
|
+
};
|
|
12
|
+
readonly calendar: {
|
|
13
|
+
readonly label: "Google Calendar";
|
|
14
|
+
readonly scopes: readonly ["https://www.googleapis.com/auth/calendar.readonly"];
|
|
15
|
+
readonly api: "calendar-json.googleapis.com";
|
|
16
|
+
};
|
|
17
|
+
readonly contacts: {
|
|
18
|
+
readonly label: "Google Contacts";
|
|
19
|
+
readonly scopes: readonly ["https://www.googleapis.com/auth/contacts.readonly"];
|
|
20
|
+
readonly api: "people.googleapis.com";
|
|
21
|
+
};
|
|
22
|
+
readonly drive: {
|
|
23
|
+
readonly label: "Google Drive";
|
|
24
|
+
readonly scopes: readonly ["https://www.googleapis.com/auth/drive.readonly"];
|
|
25
|
+
readonly api: "drive.googleapis.com";
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
export type GoogleService = keyof typeof GOOGLE_SERVICES;
|
|
29
|
+
export declare const isGoogleService: (name: string) => name is GoogleService;
|
|
30
|
+
//# sourceMappingURL=services.d.ts.map
|
package/dist/services.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Google services Chronicle sources read, by the name a person passes to
|
|
3
|
+
* `chronicle auth login google --add`: the read-only scope each needs and
|
|
4
|
+
* the API that has to be on in the person's Google Cloud project.
|
|
5
|
+
*/
|
|
6
|
+
export const GOOGLE_SERVICES = {
|
|
7
|
+
gmail: {
|
|
8
|
+
label: 'Gmail',
|
|
9
|
+
scopes: ['https://www.googleapis.com/auth/gmail.readonly'],
|
|
10
|
+
api: 'gmail.googleapis.com',
|
|
11
|
+
},
|
|
12
|
+
calendar: {
|
|
13
|
+
label: 'Google Calendar',
|
|
14
|
+
scopes: ['https://www.googleapis.com/auth/calendar.readonly'],
|
|
15
|
+
api: 'calendar-json.googleapis.com',
|
|
16
|
+
},
|
|
17
|
+
contacts: {
|
|
18
|
+
label: 'Google Contacts',
|
|
19
|
+
scopes: ['https://www.googleapis.com/auth/contacts.readonly'],
|
|
20
|
+
api: 'people.googleapis.com',
|
|
21
|
+
},
|
|
22
|
+
drive: {
|
|
23
|
+
label: 'Google Drive',
|
|
24
|
+
scopes: ['https://www.googleapis.com/auth/drive.readonly'],
|
|
25
|
+
api: 'drive.googleapis.com',
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
export const isGoogleService = (name) => Object.hasOwn(GOOGLE_SERVICES, name);
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ClientCredentials, type ProviderSetupContext } from '@chronicle.app/auth';
|
|
2
|
+
import { type Gcloud } from './gcloud.js';
|
|
3
|
+
/** Stand-ins for the machine, so tests never reach gcloud. */
|
|
4
|
+
export interface SetupOptions {
|
|
5
|
+
gcloud?: Gcloud;
|
|
6
|
+
/** A new project's ID. */
|
|
7
|
+
newProjectId?: () => string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Walk a person through making their own Google OAuth client, once: a Google
|
|
11
|
+
* Cloud project with the APIs their sources read, a consent screen, and a
|
|
12
|
+
* desktop client. gcloud does what it can (the project and APIs), in a config
|
|
13
|
+
* directory of Chronicle's own; the consent screen and the client have no
|
|
14
|
+
* API, so the person makes them in the Cloud console, one page at a time.
|
|
15
|
+
*
|
|
16
|
+
* Each finished step is saved, so stopping halfway loses nothing. Later runs
|
|
17
|
+
* reuse the client, and only turn on the APIs for access added since.
|
|
18
|
+
*/
|
|
19
|
+
export declare function setupGoogleClient(context: ProviderSetupContext, options?: SetupOptions): Promise<ClientCredentials>;
|
|
20
|
+
//# sourceMappingURL=setup.d.ts.map
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { CredentialManager, } from '@chronicle.app/auth';
|
|
6
|
+
import { EXIT_CODES, ExtractorError } from '@chronicle.app/logging';
|
|
7
|
+
import { CommandLineGcloud, GcloudError } from './gcloud.js';
|
|
8
|
+
import { GOOGLE_SERVICES, isGoogleService } from './services.js';
|
|
9
|
+
const CONSOLE = 'https://console.cloud.google.com';
|
|
10
|
+
/** A Google Cloud project ID: 6 to 30 lowercase letters, digits, and hyphens. */
|
|
11
|
+
const PROJECT_ID = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/;
|
|
12
|
+
/**
|
|
13
|
+
* Walk a person through making their own Google OAuth client, once: a Google
|
|
14
|
+
* Cloud project with the APIs their sources read, a consent screen, and a
|
|
15
|
+
* desktop client. gcloud does what it can (the project and APIs), in a config
|
|
16
|
+
* directory of Chronicle's own; the consent screen and the client have no
|
|
17
|
+
* API, so the person makes them in the Cloud console, one page at a time.
|
|
18
|
+
*
|
|
19
|
+
* Each finished step is saved, so stopping halfway loses nothing. Later runs
|
|
20
|
+
* reuse the client, and only turn on the APIs for access added since.
|
|
21
|
+
*/
|
|
22
|
+
export async function setupGoogleClient(context, options = {}) {
|
|
23
|
+
const dir = join(context.configDir, 'google');
|
|
24
|
+
const statePath = join(dir, 'setup.json');
|
|
25
|
+
const gcloud = options.gcloud ?? new CommandLineGcloud(join(dir, 'gcloud'));
|
|
26
|
+
const { logger } = context;
|
|
27
|
+
const state = context.fresh ? {} : await load(statePath);
|
|
28
|
+
const save = async () => {
|
|
29
|
+
await mkdir(dir, { recursive: true });
|
|
30
|
+
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
};
|
|
32
|
+
// A client passed with --client-id on an earlier sign-in counts too.
|
|
33
|
+
if (!state.client && !context.fresh) {
|
|
34
|
+
const stored = await CredentialManager.getCredentials('google');
|
|
35
|
+
if (stored?.clientId) {
|
|
36
|
+
state.client = { clientId: stored.clientId, clientSecret: stored.clientSecret };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// A client ID starts with its project's number, which gcloud and the
|
|
40
|
+
// console take in place of the ID: a client found without its setup state
|
|
41
|
+
// still has a project to turn APIs on in.
|
|
42
|
+
if (state.client && !state.projectId) {
|
|
43
|
+
state.projectId = state.client.clientId.match(/^(\d+)-/)?.[1];
|
|
44
|
+
}
|
|
45
|
+
const services = context.scopeSets.filter(set => isGoogleService(set));
|
|
46
|
+
const apis = services.map(service => GOOGLE_SERVICES[service].api);
|
|
47
|
+
const missingApis = state.projectId ? apis.filter(api => !state.apis?.includes(api)) : [];
|
|
48
|
+
// Set up before: nothing to do, unless access was added since.
|
|
49
|
+
if (state.client && missingApis.length === 0) {
|
|
50
|
+
logger.debug('Using the Google client set up before', { project: state.projectId });
|
|
51
|
+
return state.client;
|
|
52
|
+
}
|
|
53
|
+
if (!context.interactive) {
|
|
54
|
+
throw new ExtractorError('Google sign-in needs a one-time setup in a terminal', {
|
|
55
|
+
code: 'setup-required',
|
|
56
|
+
exitCode: EXIT_CODES.auth,
|
|
57
|
+
hint: [
|
|
58
|
+
'Run `chronicle auth login google` in a terminal.',
|
|
59
|
+
'Or pass a client you made with `--client-file <path>`.',
|
|
60
|
+
].join('\n'),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** A section of the walkthrough: a title, a few sentences, then what to do. */
|
|
64
|
+
const guide = (title, text, steps = []) => logger.emit({ level: 'info', kind: 'guide', message: title, guide: { text, steps } });
|
|
65
|
+
// Access added to a client set up before: just turn its APIs on.
|
|
66
|
+
if (state.client && state.projectId) {
|
|
67
|
+
await enableApis(missingApis);
|
|
68
|
+
await signOut();
|
|
69
|
+
return state.client;
|
|
70
|
+
}
|
|
71
|
+
const titles = {
|
|
72
|
+
project: 'Create a Google Cloud project',
|
|
73
|
+
consent: 'Set up the sign-in screen',
|
|
74
|
+
client: 'Create the app’s credentials',
|
|
75
|
+
};
|
|
76
|
+
// Always numbered out of all three, so a step keeps its number when a
|
|
77
|
+
// second run picks up where the first stopped.
|
|
78
|
+
const order = ['project', 'consent', 'client'];
|
|
79
|
+
const done = {
|
|
80
|
+
project: Boolean(state.projectId) && missingApis.length === 0,
|
|
81
|
+
consent: Boolean(state.consentScreen),
|
|
82
|
+
client: false,
|
|
83
|
+
};
|
|
84
|
+
const steps = order.filter(name => !done[name]);
|
|
85
|
+
const step = (name) => `Step ${order.indexOf(name) + 1} of ${order.length}: ${titles[name]}`;
|
|
86
|
+
const overview = order.map(name => (done[name] ? `${titles[name]} (done)` : titles[name]));
|
|
87
|
+
if (steps.length < order.length) {
|
|
88
|
+
guide('Setting up Google access', ['Picking up where you left off.'], overview);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
guide('Setting up Google access', [
|
|
92
|
+
'To read your Gmail and Calendar, Chronicle signs in to Google as an app. Chronicle doesn’t come with one, so you’ll create your own in Google Cloud.',
|
|
93
|
+
'It’s free, only you use it, and your data goes straight from Google to this computer.',
|
|
94
|
+
'It takes about 5 minutes, once. You’ll switch between here and your browser.',
|
|
95
|
+
], overview);
|
|
96
|
+
}
|
|
97
|
+
if (steps.includes('project')) {
|
|
98
|
+
if (state.projectId) {
|
|
99
|
+
guide(step('project'), ['The project is where your app lives.']);
|
|
100
|
+
await enableApis(missingApis);
|
|
101
|
+
}
|
|
102
|
+
else if (await gcloud.available()) {
|
|
103
|
+
await projectWithGcloud();
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
await projectInConsole();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const projectId = state.projectId;
|
|
110
|
+
if (!state.consentScreen) {
|
|
111
|
+
guide(step('consent'), [
|
|
112
|
+
`This is the Google page you’ll see when you sign in. Check the project at the top of the page is \`${projectId}\`.`,
|
|
113
|
+
'If the page shows OAuth Overview with charts instead of Get started, the project already has one. Press Enter here and go on.',
|
|
114
|
+
], [
|
|
115
|
+
'Click Get started.',
|
|
116
|
+
'Name the app Chronicle, pick your email for user support, and click Next.',
|
|
117
|
+
'Pick External for the audience and click Next.',
|
|
118
|
+
'Enter your email for contact, agree to the policy, and click Create.',
|
|
119
|
+
]);
|
|
120
|
+
await context.openUrl(`${CONSOLE}/auth/overview?project=${projectId}`);
|
|
121
|
+
await context.ask('Press Enter when it’s created.');
|
|
122
|
+
guide('Publish the app', [
|
|
123
|
+
'Without this, Google signs you out every 7 days. Publishing doesn’t make the app public. It’s still only for you.',
|
|
124
|
+
'If it already says In production, there’s nothing to do.',
|
|
125
|
+
], [
|
|
126
|
+
'Check the user type is External. With Internal, only accounts in that Workspace can sign in.',
|
|
127
|
+
'Under Publishing status, click Publish app, then Confirm.',
|
|
128
|
+
]);
|
|
129
|
+
await context.openUrl(`${CONSOLE}/auth/audience?project=${projectId}`);
|
|
130
|
+
await context.ask('Press Enter when it’s published.');
|
|
131
|
+
state.consentScreen = true;
|
|
132
|
+
await save();
|
|
133
|
+
}
|
|
134
|
+
guide(step('client'), [
|
|
135
|
+
`Chronicle uses these to sign in as your app. Check the project at the top of the page is \`${projectId}\`.`,
|
|
136
|
+
'If you see a list of OAuth 2.0 Client IDs instead, click Create credentials at the top, then OAuth client ID. Clients already there won’t work unless they’re Desktop apps.',
|
|
137
|
+
], [
|
|
138
|
+
'Pick Desktop app as the type, name it Chronicle, and click Create.',
|
|
139
|
+
'The window that opens shows the client ID and secret. Copy each one and paste it here.',
|
|
140
|
+
]);
|
|
141
|
+
await context.openUrl(`${CONSOLE}/auth/clients/create?project=${projectId}`);
|
|
142
|
+
state.client = await askClient(context);
|
|
143
|
+
await save();
|
|
144
|
+
// Setup is done: its gcloud sign-in isn't needed until access is added.
|
|
145
|
+
await signOut();
|
|
146
|
+
guide('Your app is ready', [
|
|
147
|
+
'Next, sign in with the Google account whose mail and calendar you want.',
|
|
148
|
+
'Google warns that it hasn’t verified the app. That’s expected for an app only you use.',
|
|
149
|
+
], [
|
|
150
|
+
'Click Continue, or Advanced and then Go to Chronicle.',
|
|
151
|
+
'Tick every box, so Chronicle can read your mail and calendar.',
|
|
152
|
+
]);
|
|
153
|
+
logger.emit({
|
|
154
|
+
level: 'info',
|
|
155
|
+
kind: 'hint',
|
|
156
|
+
message: 'To add another account later, run `chronicle auth login google` again.',
|
|
157
|
+
});
|
|
158
|
+
return state.client;
|
|
159
|
+
async function projectWithGcloud() {
|
|
160
|
+
let account = await gcloud.account();
|
|
161
|
+
guide(step('project'), [
|
|
162
|
+
'The project is where your app lives. Chronicle creates it for you with gcloud.',
|
|
163
|
+
...(account
|
|
164
|
+
? []
|
|
165
|
+
: [
|
|
166
|
+
'First, sign in to Google Cloud with the account that will own the app. Your personal account is a good choice. Any of your Google accounts, personal or work, can use the app later.',
|
|
167
|
+
'If you already use gcloud, this sign-in is kept separate and won’t change it. Chronicle signs out again when setup is done.',
|
|
168
|
+
]),
|
|
169
|
+
]);
|
|
170
|
+
if (!account) {
|
|
171
|
+
await context.ask('Press Enter to open the sign-in page.');
|
|
172
|
+
await gcloud.login();
|
|
173
|
+
account = await gcloud.account();
|
|
174
|
+
if (!account) {
|
|
175
|
+
throw new ExtractorError('The Google Cloud sign-in didn’t finish', {
|
|
176
|
+
code: 'setup-failed',
|
|
177
|
+
exitCode: EXIT_CODES.auth,
|
|
178
|
+
hint: 'Run `chronicle auth login google` again to pick up where you left off.',
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
state.account = account;
|
|
183
|
+
if (!state.projectId) {
|
|
184
|
+
const projectId = options.newProjectId?.() ?? newProjectId();
|
|
185
|
+
const projects = await gcloud.listProjects().catch(() => []);
|
|
186
|
+
guide('Confirm the project', [
|
|
187
|
+
`A new project, \`${projectId}\`, owned by ${account}.`,
|
|
188
|
+
...(projects.length > 0
|
|
189
|
+
? [
|
|
190
|
+
`To use a project you already have instead, paste its ID. Yours are ${projects.map(id => `\`${id}\``).join(', ')}.`,
|
|
191
|
+
]
|
|
192
|
+
: []),
|
|
193
|
+
]);
|
|
194
|
+
const chosen = await pickProject('Press Enter to create it, or paste a project ID:', projects);
|
|
195
|
+
if (chosen) {
|
|
196
|
+
state.projectId = chosen;
|
|
197
|
+
await save();
|
|
198
|
+
await enableApis(apis);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
logger.info('Creating a Google Cloud project', { project: projectId });
|
|
202
|
+
try {
|
|
203
|
+
await gcloud.createProject(projectId, 'Chronicle');
|
|
204
|
+
state.projectId = projectId;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (!(error instanceof GcloudError && /quota/i.test(error.said))) {
|
|
208
|
+
throw gcloudFailure(error, 'Couldn’t create a Google Cloud project');
|
|
209
|
+
}
|
|
210
|
+
state.projectId = await existingProject();
|
|
211
|
+
}
|
|
212
|
+
await save();
|
|
213
|
+
}
|
|
214
|
+
await enableApis(apis);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* At the project limit, an existing project can hold the app instead: the
|
|
218
|
+
* person picks one of theirs, or stops to delete one.
|
|
219
|
+
*/
|
|
220
|
+
async function existingProject() {
|
|
221
|
+
const projects = await gcloud.listProjects().catch(() => []);
|
|
222
|
+
guide('You’ve reached your Google Cloud project limit', [
|
|
223
|
+
'Google limits how many projects an account can have. Projects you deleted still count for 30 days.',
|
|
224
|
+
'You can put the app in a project you already have instead. It only adds the app and turns on the APIs.',
|
|
225
|
+
...(projects.length > 0
|
|
226
|
+
? [`Your projects: ${projects.map(id => `\`${id}\``).join(', ')}`]
|
|
227
|
+
: []),
|
|
228
|
+
], [
|
|
229
|
+
'Paste the ID of the project to use.',
|
|
230
|
+
`Or press Enter to stop, delete a project you don’t need at \`${CONSOLE}/cloud-resource-manager\`, and run \`chronicle auth login google\` again.`,
|
|
231
|
+
]);
|
|
232
|
+
const chosen = await pickProject('Project ID to use:', projects);
|
|
233
|
+
if (!chosen) {
|
|
234
|
+
throw new ExtractorError('No Google Cloud project to use', {
|
|
235
|
+
code: 'setup-failed',
|
|
236
|
+
exitCode: EXIT_CODES.auth,
|
|
237
|
+
hint: `Delete a project you don’t need at \`${CONSOLE}/cloud-resource-manager\`.\nThen run \`chronicle auth login google\` again.`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return chosen;
|
|
241
|
+
}
|
|
242
|
+
/** One of `projects`, as the person pastes it, or undefined for just Enter. */
|
|
243
|
+
async function pickProject(prompt, projects) {
|
|
244
|
+
for (;;) {
|
|
245
|
+
const answer = await context.ask(prompt);
|
|
246
|
+
if (!answer)
|
|
247
|
+
return undefined;
|
|
248
|
+
if (!PROJECT_ID.test(answer)) {
|
|
249
|
+
logger.emit({
|
|
250
|
+
level: 'warn',
|
|
251
|
+
kind: 'notice',
|
|
252
|
+
message: 'That doesn’t look like a project ID',
|
|
253
|
+
hint: { action: 'Paste an ID like chronicle-123456, or press Enter.' },
|
|
254
|
+
});
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (projects.length === 0 || projects.includes(answer))
|
|
258
|
+
return answer;
|
|
259
|
+
logger.emit({
|
|
260
|
+
level: 'warn',
|
|
261
|
+
kind: 'notice',
|
|
262
|
+
message: 'That isn’t one of your projects',
|
|
263
|
+
hint: { action: 'Paste one of the IDs above, or press Enter.' },
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function projectInConsole() {
|
|
268
|
+
guide(step('project'), [
|
|
269
|
+
'The project is where your app lives.',
|
|
270
|
+
'With gcloud installed, this step happens for you: `https://cloud.google.com/sdk/docs/install`',
|
|
271
|
+
], [
|
|
272
|
+
'Sign in with the Google account that will own the app. Your personal account is a good choice.',
|
|
273
|
+
'Name the project Chronicle and click Create.',
|
|
274
|
+
'Copy its project ID, like chronicle-123456, and paste it here.',
|
|
275
|
+
]);
|
|
276
|
+
await context.openUrl(`${CONSOLE}/projectcreate`);
|
|
277
|
+
state.projectId = await askProjectId();
|
|
278
|
+
await save();
|
|
279
|
+
await enableApis(apis);
|
|
280
|
+
}
|
|
281
|
+
async function askProjectId() {
|
|
282
|
+
for (;;) {
|
|
283
|
+
const answer = await context.ask('Paste the project ID:');
|
|
284
|
+
if (PROJECT_ID.test(answer))
|
|
285
|
+
return answer;
|
|
286
|
+
logger.emit({
|
|
287
|
+
level: 'warn',
|
|
288
|
+
kind: 'notice',
|
|
289
|
+
message: 'That doesn’t look like a project ID',
|
|
290
|
+
hint: { action: 'Copy the ID under the project name, like chronicle-123456.' },
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Sign out of the gcloud setup used, if it signed in. */
|
|
295
|
+
async function signOut() {
|
|
296
|
+
if (state.account && (await gcloud.available()))
|
|
297
|
+
await gcloud.signOut();
|
|
298
|
+
}
|
|
299
|
+
/** Turn on APIs: with gcloud when it's there, otherwise on a console page. */
|
|
300
|
+
async function enableApis(wanted) {
|
|
301
|
+
const projectId = state.projectId;
|
|
302
|
+
const labels = Object.values(GOOGLE_SERVICES)
|
|
303
|
+
.filter(service => wanted.includes(service.api))
|
|
304
|
+
.map(service => service.label);
|
|
305
|
+
if (wanted.length === 0)
|
|
306
|
+
return;
|
|
307
|
+
if (state.account && (await gcloud.available())) {
|
|
308
|
+
if (!(await gcloud.account())) {
|
|
309
|
+
guide(`Sign in to Google Cloud to turn on the ${list(labels)} APIs`, [
|
|
310
|
+
`Use ${state.account}, the account that owns the project. Chronicle signs out again when they’re on.`,
|
|
311
|
+
]);
|
|
312
|
+
await context.ask('Press Enter to open the sign-in page.');
|
|
313
|
+
await gcloud.login();
|
|
314
|
+
}
|
|
315
|
+
logger.info(`Turning on the ${list(labels)} APIs`, { project: projectId });
|
|
316
|
+
try {
|
|
317
|
+
await gcloud.enableApis(projectId, wanted);
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
throw gcloudFailure(error, `Couldn’t turn on the ${list(labels)} APIs`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
guide(`Turn on the ${list(labels)} APIs`, [], [`Check the project is ${projectId}, then click Next and Enable.`]);
|
|
325
|
+
await context.openUrl(`${CONSOLE}/flows/enableapi?apiid=${wanted.join(',')}&project=${projectId}`);
|
|
326
|
+
await context.ask('Press Enter when they’re on.');
|
|
327
|
+
}
|
|
328
|
+
state.apis = [...new Set([...(state.apis ?? []), ...wanted])];
|
|
329
|
+
await save();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The client the person made, pasted from the console's window: its ID, then
|
|
334
|
+
* its secret. A path to its downloaded JSON file works in place of the ID.
|
|
335
|
+
*/
|
|
336
|
+
async function askClient(context) {
|
|
337
|
+
const { logger } = context;
|
|
338
|
+
const warn = (message, action) => logger.emit({ level: 'warn', kind: 'notice', message, hint: { action } });
|
|
339
|
+
for (;;) {
|
|
340
|
+
const answer = await context.ask('Client ID:');
|
|
341
|
+
if (/\.json$/i.test(answer)) {
|
|
342
|
+
try {
|
|
343
|
+
return await readClientFile(answer.replace(/^~(?=$|\/)/, homedir()));
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
warn(error instanceof Error ? error.message : String(error), 'Paste the client ID instead.');
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (!/\.apps\.googleusercontent\.com$/.test(answer)) {
|
|
351
|
+
warn('That doesn’t look like a client ID', 'It ends in .apps.googleusercontent.com. Copy it from the window that opened.');
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
for (;;) {
|
|
355
|
+
const clientSecret = await context.ask('Client secret:');
|
|
356
|
+
if (clientSecret)
|
|
357
|
+
return { clientId: answer, clientSecret };
|
|
358
|
+
warn('No client secret given', 'Copy it from the same window, under the client ID.');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** A desktop client as the console downloads it: `{ "installed": { client_id, … } }`. */
|
|
363
|
+
async function readClientFile(path) {
|
|
364
|
+
let parsed;
|
|
365
|
+
try {
|
|
366
|
+
parsed = JSON.parse(await readFile(path, 'utf-8'));
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
throw new Error('Can’t read that client file');
|
|
370
|
+
}
|
|
371
|
+
if (parsed?.web) {
|
|
372
|
+
throw new Error('That’s a web client. Chronicle needs a Desktop app client');
|
|
373
|
+
}
|
|
374
|
+
const client = parsed?.installed;
|
|
375
|
+
if (typeof client?.client_id !== 'string' || typeof client?.client_secret !== 'string') {
|
|
376
|
+
throw new TypeError('That isn’t a Google OAuth client file');
|
|
377
|
+
}
|
|
378
|
+
return { clientId: client.client_id, clientSecret: client.client_secret };
|
|
379
|
+
}
|
|
380
|
+
async function load(path) {
|
|
381
|
+
try {
|
|
382
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return {};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** `chronicle-` and six random characters: project IDs are unique across Google. */
|
|
389
|
+
function newProjectId() {
|
|
390
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
391
|
+
const bytes = randomBytes(6);
|
|
392
|
+
return `chronicle-${[...bytes].map(byte => alphabet[byte % alphabet.length]).join('')}`;
|
|
393
|
+
}
|
|
394
|
+
function gcloudFailure(error, message) {
|
|
395
|
+
const said = error instanceof GcloudError ? error.stderr : String(error);
|
|
396
|
+
if (/terms of service/i.test(said)) {
|
|
397
|
+
return new ExtractorError('Google Cloud needs you to accept its terms first', {
|
|
398
|
+
code: 'setup-failed',
|
|
399
|
+
exitCode: EXIT_CODES.auth,
|
|
400
|
+
hint: [
|
|
401
|
+
`Open ${CONSOLE} and accept the terms.`,
|
|
402
|
+
'Then run `chronicle auth login google` again.',
|
|
403
|
+
].join('\n'),
|
|
404
|
+
cause: error,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
// What gcloud said, on a line of its own under the message.
|
|
408
|
+
const reason = error instanceof GcloudError ? error.said : said;
|
|
409
|
+
return new ExtractorError(reason ? `${message}\n${reason}` : message, {
|
|
410
|
+
code: 'setup-failed',
|
|
411
|
+
exitCode: EXIT_CODES.auth,
|
|
412
|
+
hint: 'Run `chronicle auth login google` again to pick up where you left off.',
|
|
413
|
+
cause: error,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const list = (items) => items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|