@globio/cli 0.1.2 → 0.1.4

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/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globio/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "license": "MIT",
5
5
  "exports": "./src/index.ts"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globio/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "The official CLI for Globio — game backend as a service",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/auth/login.ts CHANGED
@@ -1,72 +1,179 @@
1
1
  import * as p from '@clack/prompts';
2
+ import { exec } from 'child_process';
2
3
  import chalk from 'chalk';
3
4
  import { config } from '../lib/config.js';
5
+ import { getConsoleCliAuthUrl, manageRequest, type ManageAccount } from '../lib/manage.js';
4
6
  import { getCliVersion, muted, orange, printBanner } from '../lib/banner.js';
5
7
 
6
- const DEFAULT_BASE_URL = 'https://api.globio.stanlink.online';
7
8
  const version = getCliVersion();
8
9
 
9
- export async function login() {
10
- printBanner(version);
10
+ function openBrowser(url: string) {
11
+ const command = process.platform === 'win32'
12
+ ? `start "" "${url}"`
13
+ : process.platform === 'darwin'
14
+ ? `open "${url}"`
15
+ : `xdg-open "${url}"`;
16
+
17
+ exec(command);
18
+ }
11
19
 
12
- const values = await p.group(
13
- {
14
- apiKey: () =>
15
- p.text({
16
- message: 'Paste your Globio API key',
17
- placeholder: 'gk_live_...',
18
- validate: (value) => (!value ? 'API key is required' : undefined),
19
- }),
20
- projectId: () =>
21
- p.text({
22
- message: 'Paste your Project ID',
23
- placeholder: 'proj_...',
24
- validate: (value) => (!value ? 'Project ID is required' : undefined),
25
- }),
20
+ function sleep(ms: number) {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+
24
+ async function savePat(token: string) {
25
+ const account = await manageRequest<ManageAccount>('/account', { token });
26
+ return account;
27
+ }
28
+
29
+ async function runTokenLogin(profileName: string) {
30
+ const hadProfiles = config.listProfiles().length > 0;
31
+ const token = await p.text({
32
+ message: 'Paste your personal access token',
33
+ placeholder: 'glo_pat_...',
34
+ validate: (value) => {
35
+ if (!value) return 'Personal access token is required';
36
+ if (!value.startsWith('glo_pat_')) return 'Token must start with glo_pat_';
37
+ return undefined;
26
38
  },
27
- {
28
- onCancel: () => {
29
- p.cancel('Login cancelled.');
30
- process.exit(0);
31
- },
32
- }
33
- );
39
+ });
34
40
 
35
- const spinner = p.spinner();
36
- spinner.start('Validating credentials...');
41
+ if (p.isCancel(token)) {
42
+ p.cancel('Login cancelled.');
43
+ process.exit(0);
44
+ }
37
45
 
46
+ const spinner = p.spinner();
47
+ spinner.start('Validating personal access token...');
38
48
  try {
39
- const response = await fetch(`${DEFAULT_BASE_URL}/id/health`, {
40
- headers: {
41
- 'X-Globio-Key': values.apiKey as string,
42
- },
49
+ const account = await savePat(token);
50
+ config.setProfile(profileName, {
51
+ pat: token,
52
+ account_email: account.email,
53
+ account_name: account.display_name ?? account.email,
54
+ created_at: Date.now(),
43
55
  });
56
+ if (profileName === 'default' || !hadProfiles) {
57
+ config.setActiveProfile(profileName);
58
+ }
59
+ spinner.stop('Token validated.');
60
+ p.outro(`Logged in as ${account.email}\nProfile: ${profileName}`);
61
+ } catch (error) {
62
+ spinner.stop('Validation failed.');
63
+ p.outro(chalk.red(error instanceof Error ? error.message : 'Could not validate token'));
64
+ process.exit(1);
65
+ }
66
+ }
67
+
68
+ async function runBrowserLogin(profileName: string) {
69
+ const state = crypto.randomUUID();
70
+ const spinner = p.spinner();
71
+ const hadProfiles = config.listProfiles().length > 0;
72
+
73
+ await manageRequest('/cli-auth/request', {
74
+ method: 'POST',
75
+ body: { state },
76
+ });
77
+
78
+ const url = getConsoleCliAuthUrl(state);
79
+ openBrowser(url);
80
+ console.log(' ' + muted('Browser URL: ') + orange(url));
81
+ console.log('');
82
+
83
+ spinner.start('Waiting for browser approval...');
84
+ const deadline = Date.now() + 5 * 60 * 1000;
85
+
86
+ while (Date.now() < deadline) {
87
+ try {
88
+ const status = await manageRequest<{ status: 'pending' | 'approved' | 'expired'; code?: string }>(
89
+ `/cli-auth/poll?state=${encodeURIComponent(state)}`
90
+ );
44
91
 
45
- if (!response.ok) {
46
- spinner.stop('Validation failed.');
47
- p.outro(chalk.red('Invalid API key or project ID.'));
48
- process.exit(1);
92
+ if (status.status === 'expired') {
93
+ spinner.stop('Approval window expired.');
94
+ p.outro(chalk.red('CLI auth request expired. Try again or use globio login --token.'));
95
+ process.exit(1);
96
+ }
97
+
98
+ if (status.status === 'approved' && status.code) {
99
+ const exchange = await manageRequest<{
100
+ token: string;
101
+ account: { email: string; display_name: string | null };
102
+ }>('/cli-auth/exchange', {
103
+ method: 'POST',
104
+ body: { code: status.code },
105
+ });
106
+
107
+ config.setProfile(profileName, {
108
+ pat: exchange.token,
109
+ account_email: exchange.account.email,
110
+ account_name: exchange.account.display_name ?? exchange.account.email,
111
+ created_at: Date.now(),
112
+ });
113
+ if (profileName === 'default' || !hadProfiles) {
114
+ config.setActiveProfile(profileName);
115
+ }
116
+
117
+ spinner.stop('Browser approval received.');
118
+ p.outro(`Logged in as ${exchange.account.email}\nProfile: ${profileName}`);
119
+ return;
120
+ }
121
+ } catch {
122
+ // Keep polling until timeout.
49
123
  }
50
124
 
51
- config.set({
52
- apiKey: values.apiKey as string,
53
- projectId: values.projectId as string,
125
+ await sleep(2000);
126
+ }
127
+
128
+ spinner.stop('Approval timed out.');
129
+ p.outro(chalk.red('Timed out waiting for browser approval. Try again or use globio login --token.'));
130
+ process.exit(1);
131
+ }
132
+
133
+ export async function login(options: { token?: boolean; profile?: string } = {}) {
134
+ printBanner(version);
135
+ const profileName = options.profile ?? 'default';
136
+ const existing = config.getProfile(profileName);
137
+
138
+ if (existing) {
139
+ const proceed = await p.confirm({
140
+ message: `Already logged in as ${existing.account_email} on profile "${profileName}". Replace?`,
141
+ initialValue: false,
54
142
  });
55
143
 
56
- spinner.stop('Credentials validated.');
57
- p.outro(
58
- ' Logged in.\n\n' +
59
- ' ' +
60
- muted('API Key: ') +
61
- orange(values.apiKey as string) +
62
- '\n' +
63
- ' ' +
64
- muted('Project: ') +
65
- orange(values.projectId as string)
66
- );
67
- } catch {
68
- spinner.stop('');
69
- p.outro(chalk.red('Could not connect to Globio. Check your credentials.'));
144
+ if (p.isCancel(proceed) || !proceed) {
145
+ p.outro('Login cancelled.');
146
+ return;
147
+ }
148
+ }
149
+
150
+ if (options.token) {
151
+ await runTokenLogin(profileName);
152
+ return;
153
+ }
154
+
155
+ const choice = await p.select({
156
+ message: 'Choose a login method',
157
+ options: [
158
+ { value: 'browser', label: 'Browser', hint: 'Open console and approve access' },
159
+ { value: 'token', label: 'Token', hint: 'Paste a personal access token' },
160
+ ],
161
+ });
162
+
163
+ if (p.isCancel(choice)) {
164
+ p.cancel('Login cancelled.');
165
+ process.exit(0);
166
+ }
167
+
168
+ if (choice === 'token') {
169
+ await runTokenLogin(profileName);
170
+ return;
171
+ }
172
+
173
+ try {
174
+ await runBrowserLogin(profileName);
175
+ } catch (error) {
176
+ p.outro(chalk.red(error instanceof Error ? error.message : 'Could not connect to Globio.'));
70
177
  process.exit(1);
71
178
  }
72
179
  }
@@ -1,8 +1,30 @@
1
- import * as p from '@clack/prompts';
2
1
  import chalk from 'chalk';
3
2
  import { config } from '../lib/config.js';
4
3
 
5
- export async function logout() {
6
- config.clear();
7
- p.outro(chalk.green('Logged out.'));
4
+ export async function logout(options: { profile?: string } = {}) {
5
+ const activeProfile = config.getActiveProfile();
6
+ const profileName = options.profile ?? activeProfile;
7
+ const profile = profileName ? config.getProfile(profileName) : null;
8
+
9
+ if (!profileName || !profile) {
10
+ console.log(chalk.yellow(`No active session on profile "${profileName || 'default'}".`));
11
+ return;
12
+ }
13
+
14
+ config.deleteProfile(profileName);
15
+
16
+ if (profileName === activeProfile) {
17
+ const remaining = config.listProfiles();
18
+ if (remaining.length > 0) {
19
+ config.setActiveProfile(remaining[0]);
20
+ console.log(chalk.green(`Logged out. Switched to profile: ${remaining[0]}`));
21
+ return;
22
+ }
23
+
24
+ config.setActiveProfile('');
25
+ console.log(chalk.green('Logged out.'));
26
+ return;
27
+ }
28
+
29
+ console.log(chalk.green(`Logged out profile: ${profileName}`));
8
30
  }
@@ -0,0 +1,20 @@
1
+ import chalk from 'chalk';
2
+ import { orange } from '../lib/banner.js';
3
+ import { config } from '../lib/config.js';
4
+
5
+ export async function useProfile(profileName: string) {
6
+ const profile = config.getProfile(profileName);
7
+ if (!profile) {
8
+ console.log(
9
+ chalk.red(
10
+ `Profile "${profileName}" not found. Run: globio login --profile ${profileName}`
11
+ )
12
+ );
13
+ process.exit(1);
14
+ }
15
+
16
+ config.setActiveProfile(profileName);
17
+ console.log(
18
+ chalk.green('Switched to profile: ') + orange(profileName) + ` (${profile.account_email})`
19
+ );
20
+ }
@@ -1,15 +1,37 @@
1
1
  import chalk from 'chalk';
2
2
  import { config } from '../lib/config.js';
3
+ import { muted, orange } from '../lib/banner.js';
3
4
 
4
- export async function whoami() {
5
- const cfg = config.get();
6
- if (!cfg.apiKey) {
7
- console.log(chalk.red('Not logged in.'));
5
+ export async function whoami(options: { profile?: string } = {}) {
6
+ const profileName = options.profile ?? config.getActiveProfile() ?? 'default';
7
+ const profile = config.getProfile(profileName);
8
+
9
+ if (!profile) {
10
+ console.log(chalk.red('Not logged in. Run: globio login'));
8
11
  return;
9
12
  }
10
13
 
14
+ const allProfiles = config.listProfiles();
15
+ const activeProfile = config.getActiveProfile();
16
+
11
17
  console.log('');
12
- console.log(chalk.cyan('API Key: ') + cfg.apiKey);
13
- console.log(chalk.cyan('Project: ') + (cfg.projectId ?? 'none'));
18
+ console.log(
19
+ muted('Profile: ') + orange(profileName) + (profileName === activeProfile ? muted(' (active)') : '')
20
+ );
21
+ console.log(muted('Account: ') + profile.account_email);
22
+ console.log(muted('Name: ') + (profile.account_name || '—'));
23
+ console.log(
24
+ muted('Project: ') +
25
+ (profile.active_project_id
26
+ ? orange(profile.active_project_name || 'unnamed') + muted(` (${profile.active_project_id})`)
27
+ : chalk.gray('none — run: globio projects use <id>'))
28
+ );
29
+
30
+ if (allProfiles.length > 1) {
31
+ console.log('');
32
+ console.log(
33
+ muted('Other profiles: ') + allProfiles.filter((name) => name !== profileName).join(', ')
34
+ );
35
+ }
14
36
  console.log('');
15
37
  }
@@ -2,11 +2,17 @@ import chalk from 'chalk';
2
2
  import type { CodeFunction, CodeInvocation } from '@globio/sdk';
3
3
  import ora from 'ora';
4
4
  import { existsSync, readFileSync, writeFileSync } from 'fs';
5
+ import { config } from '../lib/config.js';
5
6
  import { gold, muted, orange } from '../lib/banner.js';
6
7
  import { getClient } from '../lib/sdk.js';
7
8
 
8
- export async function functionsList() {
9
- const client = getClient();
9
+ function resolveProfileName(profile?: string) {
10
+ return profile ?? config.getActiveProfile() ?? 'default';
11
+ }
12
+
13
+ export async function functionsList(options: { profile?: string } = {}) {
14
+ const profileName = resolveProfileName(options.profile);
15
+ const client = getClient(profileName);
10
16
  const spinner = ora('Fetching functions...').start();
11
17
  const result = await client.code.listFunctions();
12
18
  spinner.stop();
@@ -29,7 +35,7 @@ export async function functionsList() {
29
35
  console.log('');
30
36
  }
31
37
 
32
- export async function functionsCreate(slug: string) {
38
+ export async function functionsCreate(slug: string, _options: { profile?: string } = {}) {
33
39
  const filename = `${slug}.js`;
34
40
  if (existsSync(filename)) {
35
41
  console.log(chalk.yellow(`${filename} already exists.`));
@@ -60,7 +66,7 @@ async function handler(input, globio) {
60
66
 
61
67
  export async function functionsDeploy(
62
68
  slug: string,
63
- options: { file?: string; name?: string }
69
+ options: { file?: string; name?: string; profile?: string }
64
70
  ) {
65
71
  const filename = options.file ?? `${slug}.js`;
66
72
  if (!existsSync(filename)) {
@@ -73,7 +79,8 @@ export async function functionsDeploy(
73
79
  }
74
80
 
75
81
  const code = readFileSync(filename, 'utf-8');
76
- const client = getClient();
82
+ const profileName = resolveProfileName(options.profile);
83
+ const client = getClient(profileName);
77
84
  const spinner = ora(`Deploying ${slug}...`).start();
78
85
  const existing = await client.code.getFunction(slug);
79
86
 
@@ -103,7 +110,7 @@ export async function functionsDeploy(
103
110
 
104
111
  export async function functionsInvoke(
105
112
  slug: string,
106
- options: { input?: string }
113
+ options: { input?: string; profile?: string }
107
114
  ) {
108
115
  let input: Record<string, unknown> = {};
109
116
  if (options.input) {
@@ -115,7 +122,8 @@ export async function functionsInvoke(
115
122
  }
116
123
  }
117
124
 
118
- const client = getClient();
125
+ const profileName = resolveProfileName(options.profile);
126
+ const client = getClient(profileName);
119
127
  const spinner = ora(`Invoking ${slug}...`).start();
120
128
  const result = await client.code.invoke(slug, input);
121
129
  spinner.stop();
@@ -134,10 +142,11 @@ export async function functionsInvoke(
134
142
 
135
143
  export async function functionsLogs(
136
144
  slug: string,
137
- options: { limit?: string }
145
+ options: { limit?: string; profile?: string }
138
146
  ) {
139
147
  const limit = options.limit ? parseInt(options.limit, 10) : 20;
140
- const client = getClient();
148
+ const profileName = resolveProfileName(options.profile);
149
+ const client = getClient(profileName);
141
150
  const spinner = ora('Fetching invocations...').start();
142
151
  const result = await client.code.getInvocations(slug, limit);
143
152
  spinner.stop();
@@ -163,8 +172,9 @@ export async function functionsLogs(
163
172
  console.log('');
164
173
  }
165
174
 
166
- export async function functionsDelete(slug: string) {
167
- const client = getClient();
175
+ export async function functionsDelete(slug: string, options: { profile?: string } = {}) {
176
+ const profileName = resolveProfileName(options.profile);
177
+ const client = getClient(profileName);
168
178
  const spinner = ora(`Deleting ${slug}...`).start();
169
179
  const result = await client.code.deleteFunction(slug);
170
180
  if (!result.success) {
@@ -175,8 +185,13 @@ export async function functionsDelete(slug: string) {
175
185
  spinner.succeed(`Deleted ${slug}`);
176
186
  }
177
187
 
178
- export async function functionsToggle(slug: string, active: boolean) {
179
- const client = getClient();
188
+ export async function functionsToggle(
189
+ slug: string,
190
+ active: boolean,
191
+ options: { profile?: string } = {}
192
+ ) {
193
+ const profileName = resolveProfileName(options.profile);
194
+ const client = getClient(profileName);
180
195
  const spinner = ora(
181
196
  `${active ? 'Enabling' : 'Disabling'} ${slug}...`
182
197
  ).start();
@@ -10,26 +10,43 @@ import {
10
10
  } from '../lib/banner.js';
11
11
  import { promptInit } from '../prompts/init.js';
12
12
  import { migrateFirestore, migrateFirebaseStorage } from './migrate.js';
13
+ import { projectsCreate, projectsUse } from './projects.js';
13
14
 
14
15
  const version = getCliVersion();
15
16
 
16
- export async function init() {
17
+ export async function init(options: { profile?: string } = {}) {
17
18
  printBanner(version);
18
19
  p.intro(orange('⇒⇒') + ' Initialize your Globio project');
19
20
 
21
+ const profileName = options.profile ?? config.getActiveProfile() ?? 'default';
22
+ const profile = config.getProfile(profileName);
23
+ if (!profile) {
24
+ console.log('Run: npx @globio/cli login --profile ' + profileName);
25
+ process.exit(1);
26
+ }
27
+
28
+ if (!profile.active_project_id) {
29
+ await projectsCreate({ profile: profileName });
30
+ } else {
31
+ await projectsUse(profile.active_project_id, { profile: profileName });
32
+ }
33
+
20
34
  const values = await promptInit();
35
+ const activeProfile = config.getProfile(profileName);
36
+ const activeProjectKey = activeProfile?.project_api_key;
37
+ const { projectId: activeProjectId } = config.requireProject(profileName);
21
38
 
22
- config.set({
23
- apiKey: values.apiKey as string,
24
- projectId: values.projectId as string,
25
- });
39
+ if (!activeProjectKey) {
40
+ console.log('No project API key cached. Run: npx @globio/cli projects use ' + activeProjectId);
41
+ process.exit(1);
42
+ }
26
43
 
27
44
  if (!existsSync('globio.config.ts')) {
28
45
  writeFileSync(
29
46
  'globio.config.ts',
30
- `import { GlobioClient } from '@globio/sdk';
47
+ `import { Globio } from '@globio/sdk';
31
48
 
32
- export const globio = new GlobioClient({
49
+ export const globio = new Globio({
33
50
  apiKey: process.env.GLOBIO_API_KEY!,
34
51
  });
35
52
  `
@@ -38,7 +55,7 @@ export const globio = new GlobioClient({
38
55
  }
39
56
 
40
57
  if (!existsSync('.env')) {
41
- writeFileSync('.env', `GLOBIO_API_KEY=${values.apiKey}\n`);
58
+ writeFileSync('.env', `GLOBIO_API_KEY=${activeProjectKey}\n`);
42
59
  printSuccess('Created .env');
43
60
  }
44
61
 
@@ -49,6 +66,7 @@ export const globio = new GlobioClient({
49
66
  await migrateFirestore({
50
67
  from: values.serviceAccountPath as string,
51
68
  all: true,
69
+ profile: profileName,
52
70
  });
53
71
 
54
72
  const serviceAccount = JSON.parse(
@@ -59,6 +77,7 @@ export const globio = new GlobioClient({
59
77
  from: values.serviceAccountPath as string,
60
78
  bucket: `${serviceAccount.project_id}.appspot.com`,
61
79
  all: true,
80
+ profile: profileName,
62
81
  });
63
82
  }
64
83
 
@@ -70,6 +89,7 @@ export const globio = new GlobioClient({
70
89
  muted('Next steps:') +
71
90
  '\n\n' +
72
91
  ' npm install @globio/sdk\n' +
92
+ ` # active project: ${activeProjectId}\n` +
73
93
  ' npx @globio/cli functions create my-first-function'
74
94
  );
75
95
  }
@@ -11,6 +11,7 @@ import {
11
11
  import { initFirebase } from '../lib/firebase.js';
12
12
  import { createProgressBar } from '../lib/progress.js';
13
13
  import { getClient } from '../lib/sdk.js';
14
+ import { config } from '../lib/config.js';
14
15
 
15
16
  const version = getCliVersion();
16
17
 
@@ -18,6 +19,7 @@ interface MigrateFirestoreOptions {
18
19
  from: string;
19
20
  collection?: string;
20
21
  all?: boolean;
22
+ profile?: string;
21
23
  }
22
24
 
23
25
  interface MigrateStorageOptions {
@@ -25,6 +27,11 @@ interface MigrateStorageOptions {
25
27
  bucket: string;
26
28
  folder?: string;
27
29
  all?: boolean;
30
+ profile?: string;
31
+ }
32
+
33
+ function resolveProfileName(profile?: string) {
34
+ return profile ?? config.getActiveProfile() ?? 'default';
28
35
  }
29
36
 
30
37
  export async function migrateFirestore(options: MigrateFirestoreOptions) {
@@ -32,7 +39,7 @@ export async function migrateFirestore(options: MigrateFirestoreOptions) {
32
39
  p.intro(gold('⇒⇒') + ' Firebase → Globio Migration');
33
40
 
34
41
  const { firestore } = await initFirebase(options.from);
35
- const client = getClient();
42
+ const client = getClient(resolveProfileName(options.profile));
36
43
 
37
44
  let collections: string[] = [];
38
45
 
@@ -139,7 +146,7 @@ export async function migrateFirebaseStorage(options: MigrateStorageOptions) {
139
146
  p.intro(gold('⇒⇒') + ' Firebase → Globio Migration');
140
147
 
141
148
  const { storage } = await initFirebase(options.from);
142
- const client = getClient();
149
+ const client = getClient(resolveProfileName(options.profile));
143
150
 
144
151
  const bucketName = options.bucket.replace(/^gs:\/\//, '');
145
152
  const bucket = storage.bucket(bucketName);