@nometria-ai/nom 0.1.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 +112 -0
- package/package.json +47 -0
- package/src/cli.js +164 -0
- package/src/commands/deploy.js +157 -0
- package/src/commands/domain.js +57 -0
- package/src/commands/env.js +120 -0
- package/src/commands/github.js +291 -0
- package/src/commands/init.js +90 -0
- package/src/commands/login.js +280 -0
- package/src/commands/logs.js +49 -0
- package/src/commands/preview.js +60 -0
- package/src/commands/scan.js +70 -0
- package/src/commands/setup.js +854 -0
- package/src/commands/start.js +26 -0
- package/src/commands/status.js +33 -0
- package/src/commands/stop.js +24 -0
- package/src/commands/terminate.js +33 -0
- package/src/commands/upgrade.js +49 -0
- package/src/commands/whoami.js +18 -0
- package/src/lib/api.js +85 -0
- package/src/lib/auth.js +56 -0
- package/src/lib/config.js +93 -0
- package/src/lib/detect.js +88 -0
- package/src/lib/prompt.js +76 -0
- package/src/lib/spinner.js +43 -0
- package/src/lib/tar.js +55 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nom github — Manage GitHub integration via Deno functions.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* connect — Link GitHub account via OAuth in browser
|
|
6
|
+
* status — Show GitHub connection status
|
|
7
|
+
* repos — List connected repositories
|
|
8
|
+
* push — Push changes to GitHub
|
|
9
|
+
*/
|
|
10
|
+
import { createServer } from 'node:http';
|
|
11
|
+
import { execSync } from 'node:child_process';
|
|
12
|
+
import { readConfig, updateConfig } from '../lib/config.js';
|
|
13
|
+
import { requireApiKey } from '../lib/auth.js';
|
|
14
|
+
import { apiRequest } from '../lib/api.js';
|
|
15
|
+
|
|
16
|
+
const CONNECT_TIMEOUT_MS = 300_000; // 5 minutes
|
|
17
|
+
|
|
18
|
+
export async function github(flags, positionals) {
|
|
19
|
+
const sub = positionals[0];
|
|
20
|
+
|
|
21
|
+
switch (sub) {
|
|
22
|
+
case 'connect':
|
|
23
|
+
return githubConnect(flags);
|
|
24
|
+
case 'status':
|
|
25
|
+
return githubStatus(flags);
|
|
26
|
+
case 'repos':
|
|
27
|
+
return githubRepos(flags);
|
|
28
|
+
case 'push':
|
|
29
|
+
return githubPush(flags);
|
|
30
|
+
default:
|
|
31
|
+
console.log(`
|
|
32
|
+
Usage: nom github <command>
|
|
33
|
+
|
|
34
|
+
Commands:
|
|
35
|
+
connect Link your GitHub account
|
|
36
|
+
status Show connection status
|
|
37
|
+
repos List connected repositories
|
|
38
|
+
push Push changes to GitHub
|
|
39
|
+
`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function githubConnect(flags) {
|
|
44
|
+
const apiKey = requireApiKey();
|
|
45
|
+
console.log(`\n Opening browser to connect GitHub...\n`);
|
|
46
|
+
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
let resolved = false;
|
|
49
|
+
|
|
50
|
+
const server = createServer((req, res) => {
|
|
51
|
+
if (resolved) { res.writeHead(410); res.end(); return; }
|
|
52
|
+
if (!req.url) { res.writeHead(400); res.end(); return; }
|
|
53
|
+
|
|
54
|
+
const url = new URL(req.url, `http://127.0.0.1`);
|
|
55
|
+
|
|
56
|
+
if (url.pathname === '/callback') {
|
|
57
|
+
const success = url.searchParams.get('success');
|
|
58
|
+
const migrationId = url.searchParams.get('migration_id');
|
|
59
|
+
|
|
60
|
+
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
|
|
61
|
+
if (success === 'true') {
|
|
62
|
+
res.end(buildSuccessHtml());
|
|
63
|
+
resolved = true;
|
|
64
|
+
cleanup();
|
|
65
|
+
|
|
66
|
+
// Update local config
|
|
67
|
+
try {
|
|
68
|
+
updateConfig(process.cwd(), {
|
|
69
|
+
migration_id: migrationId,
|
|
70
|
+
github_connected: true,
|
|
71
|
+
});
|
|
72
|
+
} catch { /* config update is best-effort */ }
|
|
73
|
+
|
|
74
|
+
console.log(` GitHub connected successfully!`);
|
|
75
|
+
if (migrationId) console.log(` Migration ID: ${migrationId}`);
|
|
76
|
+
console.log();
|
|
77
|
+
resolve();
|
|
78
|
+
} else {
|
|
79
|
+
res.end(buildErrorHtml('GitHub connection was not completed.'));
|
|
80
|
+
resolved = true;
|
|
81
|
+
cleanup();
|
|
82
|
+
console.error(' GitHub connection failed.\n');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
res.writeHead(404);
|
|
89
|
+
res.end();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const timeout = setTimeout(() => {
|
|
93
|
+
if (!resolved) {
|
|
94
|
+
resolved = true;
|
|
95
|
+
cleanup();
|
|
96
|
+
console.error(' Connection timed out. Try again.\n');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
}, CONNECT_TIMEOUT_MS);
|
|
100
|
+
|
|
101
|
+
const cleanup = () => { clearTimeout(timeout); server.close(); };
|
|
102
|
+
|
|
103
|
+
server.on('error', (err) => {
|
|
104
|
+
if (!resolved) { resolved = true; cleanup(); reject(err); }
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
108
|
+
const addr = server.address();
|
|
109
|
+
if (!addr || typeof addr === 'string') {
|
|
110
|
+
resolved = true;
|
|
111
|
+
cleanup();
|
|
112
|
+
reject(new Error('Failed to start local server'));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const port = addr.port;
|
|
117
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const config = readConfig();
|
|
121
|
+
const appId = config.app_id || config.name;
|
|
122
|
+
const result = await apiRequest('/cli/github-connect', {
|
|
123
|
+
apiKey,
|
|
124
|
+
body: { app_id: appId, redirect_uri: redirectUri },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const oauthUrl = result.oauth_url;
|
|
128
|
+
if (!oauthUrl) {
|
|
129
|
+
resolved = true;
|
|
130
|
+
cleanup();
|
|
131
|
+
console.error(' Failed to get OAuth URL from server.\n');
|
|
132
|
+
process.exit(1);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log(` If the browser doesn't open, visit:\n ${oauthUrl}\n`);
|
|
137
|
+
console.log(` Waiting for GitHub authorization... (Ctrl+C to cancel)\n`);
|
|
138
|
+
openBrowser(oauthUrl);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
resolved = true;
|
|
141
|
+
cleanup();
|
|
142
|
+
console.error(` Failed to initiate GitHub connection: ${err.message}\n`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function githubStatus(flags) {
|
|
150
|
+
const apiKey = requireApiKey();
|
|
151
|
+
const config = readConfig();
|
|
152
|
+
const appId = config.app_id || config.name;
|
|
153
|
+
|
|
154
|
+
const result = await apiRequest('/getUserGithubConnection', {
|
|
155
|
+
apiKey,
|
|
156
|
+
body: {},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (flags.json) {
|
|
160
|
+
console.log(JSON.stringify(result, null, 2));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log(`
|
|
165
|
+
GitHub Connection
|
|
166
|
+
Connected: ${result.connected ? 'Yes' : 'No'}
|
|
167
|
+
Username: ${result.username || '—'}
|
|
168
|
+
Repository: ${result.repository || '—'}
|
|
169
|
+
Last Sync: ${result.last_sync || '—'}
|
|
170
|
+
`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function githubRepos(flags) {
|
|
174
|
+
const apiKey = requireApiKey();
|
|
175
|
+
|
|
176
|
+
const config = readConfig();
|
|
177
|
+
const migrationId = config.migration_id;
|
|
178
|
+
if (!migrationId) {
|
|
179
|
+
console.error('\n No migration_id in nometria.json. Run nom deploy first.\n');
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
const result = await apiRequest('/getGithubRepos', {
|
|
183
|
+
apiKey,
|
|
184
|
+
body: { migration_id: migrationId },
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (flags.json) {
|
|
188
|
+
console.log(JSON.stringify(result, null, 2));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const repos = result.repos || [];
|
|
193
|
+
if (!repos.length) {
|
|
194
|
+
console.log('\n No repositories found.\n');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
console.log('\n Repositories:\n');
|
|
199
|
+
for (const repo of repos) {
|
|
200
|
+
const visibility = repo.private ? 'private' : 'public';
|
|
201
|
+
console.log(` ${repo.full_name || repo.name} (${visibility})`);
|
|
202
|
+
}
|
|
203
|
+
console.log();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function githubPush(flags) {
|
|
207
|
+
const apiKey = requireApiKey();
|
|
208
|
+
const config = readConfig();
|
|
209
|
+
const appId = config.app_id || config.name;
|
|
210
|
+
const message = flags.message || flags.m || 'Update via nom';
|
|
211
|
+
|
|
212
|
+
const migrationId = config.migration_id;
|
|
213
|
+
const result = await apiRequest('/pushGithubChanges', {
|
|
214
|
+
apiKey,
|
|
215
|
+
body: { migration_id: migrationId, app_id: appId, commit_message: message },
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (flags.json) {
|
|
219
|
+
console.log(JSON.stringify(result, null, 2));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (result.success) {
|
|
224
|
+
console.log(`\n Pushed to GitHub: ${result.commit_sha || ''}`);
|
|
225
|
+
if (result.url) console.log(` ${result.url}`);
|
|
226
|
+
console.log();
|
|
227
|
+
} else {
|
|
228
|
+
console.error(`\n Push failed: ${result.error || 'Unknown error'}\n`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function openBrowser(url) {
|
|
234
|
+
try {
|
|
235
|
+
if (process.platform === 'darwin') execSync(`open "${url}"`);
|
|
236
|
+
else if (process.platform === 'win32') execSync(`start "" "${url}"`);
|
|
237
|
+
else execSync(`xdg-open "${url}"`);
|
|
238
|
+
} catch {
|
|
239
|
+
// Silently fail — user can copy the URL from the console
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function buildSuccessHtml() {
|
|
244
|
+
return `<!DOCTYPE html>
|
|
245
|
+
<html lang="en">
|
|
246
|
+
<head>
|
|
247
|
+
<meta charset="UTF-8">
|
|
248
|
+
<title>Nometria - GitHub Connected</title>
|
|
249
|
+
<style>
|
|
250
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
251
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
252
|
+
display: flex; align-items: center; justify-content: center;
|
|
253
|
+
min-height: 100vh; background: #faf9f7; color: #1a1a1a; }
|
|
254
|
+
.card { text-align: center; padding: 48px; max-width: 420px; }
|
|
255
|
+
h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
|
|
256
|
+
p { font-size: 14px; color: #666; line-height: 1.5; }
|
|
257
|
+
</style>
|
|
258
|
+
</head>
|
|
259
|
+
<body>
|
|
260
|
+
<div class="card">
|
|
261
|
+
<h1>GitHub Connected!</h1>
|
|
262
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
263
|
+
</div>
|
|
264
|
+
</body>
|
|
265
|
+
</html>`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function buildErrorHtml(message) {
|
|
269
|
+
return `<!DOCTYPE html>
|
|
270
|
+
<html lang="en">
|
|
271
|
+
<head>
|
|
272
|
+
<meta charset="UTF-8">
|
|
273
|
+
<title>Nometria - Connection Failed</title>
|
|
274
|
+
<style>
|
|
275
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
276
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
277
|
+
display: flex; align-items: center; justify-content: center;
|
|
278
|
+
min-height: 100vh; background: #faf9f7; color: #1a1a1a; }
|
|
279
|
+
.card { text-align: center; padding: 48px; max-width: 420px; }
|
|
280
|
+
h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: #dc2626; }
|
|
281
|
+
p { font-size: 14px; color: #666; line-height: 1.5; }
|
|
282
|
+
</style>
|
|
283
|
+
</head>
|
|
284
|
+
<body>
|
|
285
|
+
<div class="card">
|
|
286
|
+
<h1>Connection Failed</h1>
|
|
287
|
+
<p>${message}</p>
|
|
288
|
+
</div>
|
|
289
|
+
</body>
|
|
290
|
+
</html>`;
|
|
291
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nom init — Create nometria.json config interactively
|
|
3
|
+
*/
|
|
4
|
+
import { writeFileSync } from 'node:fs';
|
|
5
|
+
import { join, basename } from 'node:path';
|
|
6
|
+
import { detectFramework, detectPackageManager } from '../lib/detect.js';
|
|
7
|
+
import { configExists, CONFIG_FILE, VALID_PLATFORMS } from '../lib/config.js';
|
|
8
|
+
import { ask, choose, confirm } from '../lib/prompt.js';
|
|
9
|
+
|
|
10
|
+
const REGIONS = {
|
|
11
|
+
aws: ['us-east-1', 'eu-west-1', 'ap-south-1', 'af-south-1'],
|
|
12
|
+
gcp: ['us-central1', 'europe-west1', 'asia-south1'],
|
|
13
|
+
azure: ['eastus', 'westeurope', 'centralindia'],
|
|
14
|
+
digitalocean: ['nyc1', 'ams3', 'blr1'],
|
|
15
|
+
hetzner: ['fsn1', 'nbg1', 'hel1', 'ash'],
|
|
16
|
+
vercel: ['auto'],
|
|
17
|
+
render: ['auto'],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function init(flags) {
|
|
21
|
+
const dir = process.cwd();
|
|
22
|
+
|
|
23
|
+
if (configExists(dir) && !flags.yes) {
|
|
24
|
+
const overwrite = await confirm('nometria.json already exists. Overwrite?', false);
|
|
25
|
+
if (!overwrite) {
|
|
26
|
+
console.log(' Cancelled.\n');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log('\n Setting up your project for deployment\n');
|
|
32
|
+
|
|
33
|
+
// Detect framework
|
|
34
|
+
const detected = detectFramework(dir);
|
|
35
|
+
const pkgManager = detectPackageManager(dir);
|
|
36
|
+
console.log(` Detected: ${detected.framework} (${pkgManager})\n`);
|
|
37
|
+
|
|
38
|
+
// Project name
|
|
39
|
+
const dirName = basename(dir).replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
|
40
|
+
const name = flags.yes ? dirName : await ask('Project name', dirName);
|
|
41
|
+
|
|
42
|
+
// Platform
|
|
43
|
+
const platform = flags.yes ? 'aws' : await choose('Where do you want to deploy?', VALID_PLATFORMS, 0);
|
|
44
|
+
|
|
45
|
+
// Region
|
|
46
|
+
const regionOptions = REGIONS[platform] || ['us-east-1'];
|
|
47
|
+
let region = regionOptions[0];
|
|
48
|
+
if (!flags.yes && regionOptions.length > 1) {
|
|
49
|
+
region = await choose('Region', regionOptions, 0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Instance type (only for VM-based providers)
|
|
53
|
+
let instanceType = '4gb';
|
|
54
|
+
const vmProviders = ['aws', 'gcp', 'azure', 'digitalocean', 'hetzner'];
|
|
55
|
+
if (!flags.yes && vmProviders.includes(platform)) {
|
|
56
|
+
instanceType = await choose('Instance size', ['2gb', '4gb', '8gb', '16gb'], 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Build command
|
|
60
|
+
const buildCmd = detected.build.command
|
|
61
|
+
? detected.build.command.replace('npm', pkgManager)
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
const config = {
|
|
65
|
+
name,
|
|
66
|
+
framework: detected.framework,
|
|
67
|
+
platform,
|
|
68
|
+
region,
|
|
69
|
+
...(vmProviders.includes(platform) ? { instanceType } : {}),
|
|
70
|
+
build: {
|
|
71
|
+
...(buildCmd ? { command: buildCmd } : {}),
|
|
72
|
+
output: detected.build.output,
|
|
73
|
+
},
|
|
74
|
+
env: {},
|
|
75
|
+
ignore: [],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const configPath = join(dir, CONFIG_FILE);
|
|
79
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
80
|
+
|
|
81
|
+
console.log(`\n Created ${CONFIG_FILE}`);
|
|
82
|
+
|
|
83
|
+
// Auto-generate AI tool configs
|
|
84
|
+
const { setup } = await import('./setup.js');
|
|
85
|
+
await setup({ yes: true });
|
|
86
|
+
|
|
87
|
+
console.log(` Next steps:`);
|
|
88
|
+
console.log(` 1. Run nom login to authenticate`);
|
|
89
|
+
console.log(` 2. Run nom deploy to deploy\n`);
|
|
90
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nom login — Authenticate via browser (default) or API key paste.
|
|
3
|
+
*
|
|
4
|
+
* Default flow (browser):
|
|
5
|
+
* 1. Start a local HTTP server on a random port
|
|
6
|
+
* 2. Open ownmy.app/extension/login?redirect_uri=http://127.0.0.1:{port}/callback
|
|
7
|
+
* 3. User signs in with Google/GitHub/email in the browser
|
|
8
|
+
* 4. Browser redirects back with Supabase JWT token
|
|
9
|
+
* 5. CLI calls /cli/create-api-key with that JWT to generate a persistent API key
|
|
10
|
+
* 6. Saves the API key to ~/.nometria/credentials.json
|
|
11
|
+
*
|
|
12
|
+
* Fallback (--api-key):
|
|
13
|
+
* Paste an existing API key manually.
|
|
14
|
+
*/
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { execSync } from 'node:child_process';
|
|
18
|
+
import { saveApiKey } from '../lib/auth.js';
|
|
19
|
+
import { apiRequest, getBaseUrl } from '../lib/api.js';
|
|
20
|
+
import { ask } from '../lib/prompt.js';
|
|
21
|
+
|
|
22
|
+
const LOGIN_TIMEOUT_MS = 300_000; // 5 minutes
|
|
23
|
+
const MAX_BODY_BYTES = 8192;
|
|
24
|
+
|
|
25
|
+
export async function login(flags) {
|
|
26
|
+
// Manual API key flow
|
|
27
|
+
if (flags['api-key'] || flags.token) {
|
|
28
|
+
return loginWithApiKey();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Browser-based login (default)
|
|
32
|
+
return loginWithBrowser();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function loginWithBrowser() {
|
|
36
|
+
console.log(`\n Opening browser to sign in...\n`);
|
|
37
|
+
|
|
38
|
+
const state = randomBytes(16).toString('hex');
|
|
39
|
+
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
let resolved = false;
|
|
42
|
+
|
|
43
|
+
const server = createServer((req, res) => {
|
|
44
|
+
if (resolved) { res.writeHead(410); res.end(); return; }
|
|
45
|
+
if (!req.url) { res.writeHead(400); res.end(); return; }
|
|
46
|
+
|
|
47
|
+
// Serve the callback page that extracts hash params
|
|
48
|
+
if (req.url.startsWith('/callback')) {
|
|
49
|
+
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
|
|
50
|
+
res.end(buildCallbackHtml(state));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Receive the token POSTed by the callback page
|
|
55
|
+
if (req.url === '/token' && req.method === 'POST') {
|
|
56
|
+
let body = '';
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
|
|
59
|
+
req.on('data', (chunk) => {
|
|
60
|
+
bytes += chunk.length;
|
|
61
|
+
if (bytes > MAX_BODY_BYTES) { res.writeHead(413); res.end(); req.destroy(); return; }
|
|
62
|
+
body += chunk.toString();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
req.on('end', async () => {
|
|
66
|
+
if (resolved) return;
|
|
67
|
+
try {
|
|
68
|
+
const data = JSON.parse(body);
|
|
69
|
+
if (data.state !== state) { res.writeHead(403); res.end('Invalid state'); return; }
|
|
70
|
+
if (!data.access_token) { res.writeHead(400); res.end('Missing token'); return; }
|
|
71
|
+
|
|
72
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
73
|
+
res.end(JSON.stringify({ ok: true }));
|
|
74
|
+
|
|
75
|
+
resolved = true;
|
|
76
|
+
cleanup();
|
|
77
|
+
|
|
78
|
+
// Exchange JWT for persistent API key
|
|
79
|
+
await exchangeTokenForApiKey(data.access_token, data.email);
|
|
80
|
+
resolve();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
res.writeHead(400);
|
|
83
|
+
res.end('Invalid JSON');
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
res.writeHead(404);
|
|
90
|
+
res.end();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const timeout = setTimeout(() => {
|
|
94
|
+
if (!resolved) {
|
|
95
|
+
resolved = true;
|
|
96
|
+
cleanup();
|
|
97
|
+
console.error(' Login timed out. Try again or use: nom login --api-key\n');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
}, LOGIN_TIMEOUT_MS);
|
|
101
|
+
|
|
102
|
+
const cleanup = () => { clearTimeout(timeout); server.close(); };
|
|
103
|
+
|
|
104
|
+
server.on('error', (err) => {
|
|
105
|
+
if (!resolved) { resolved = true; cleanup(); reject(err); }
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
server.listen(0, '127.0.0.1', () => {
|
|
109
|
+
const addr = server.address();
|
|
110
|
+
if (!addr || typeof addr === 'string') {
|
|
111
|
+
resolved = true;
|
|
112
|
+
cleanup();
|
|
113
|
+
reject(new Error('Failed to start auth server'));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const port = addr.port;
|
|
118
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
119
|
+
const appUrl = process.env.NOMETRIA_APP_URL || 'https://ownmy.app';
|
|
120
|
+
const loginUrl = `${appUrl}/extension/login?redirect_uri=${encodeURIComponent(redirectUri)}`;
|
|
121
|
+
|
|
122
|
+
console.log(` If the browser doesn't open, visit:\n ${loginUrl}\n`);
|
|
123
|
+
console.log(` Waiting for sign-in... (Ctrl+C to cancel)\n`);
|
|
124
|
+
|
|
125
|
+
// Open browser
|
|
126
|
+
openBrowser(loginUrl);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function exchangeTokenForApiKey(jwtToken, email) {
|
|
132
|
+
process.stdout.write(' Creating API key...');
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const baseUrl = getBaseUrl();
|
|
136
|
+
const res = await fetch(`${baseUrl}/cli/create-api-key`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: {
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
'Authorization': `Bearer ${jwtToken}`,
|
|
141
|
+
},
|
|
142
|
+
body: JSON.stringify({ label: `CLI Login (${new Date().toLocaleDateString()})` }),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const raw = await res.json();
|
|
146
|
+
// server.js wraps all responses in { data: ... } — unwrap
|
|
147
|
+
const data = raw?.data !== undefined ? raw.data : raw;
|
|
148
|
+
|
|
149
|
+
if (!res.ok || !data.success) {
|
|
150
|
+
console.log(' failed.');
|
|
151
|
+
console.error(` ${data.error || 'Could not create API key'}`);
|
|
152
|
+
console.error(' Try: nom login --api-key\n');
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const savedPath = saveApiKey(data.api_key);
|
|
157
|
+
console.log(` done!`);
|
|
158
|
+
console.log(`\n Authenticated as ${data.email || email || 'unknown'}`);
|
|
159
|
+
console.log(` Credentials saved to ${savedPath}\n`);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.log(' failed.');
|
|
162
|
+
console.error(` ${err.message}`);
|
|
163
|
+
console.error(' Try: nom login --api-key\n');
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function loginWithApiKey() {
|
|
169
|
+
console.log(`
|
|
170
|
+
Log in with API key
|
|
171
|
+
|
|
172
|
+
1. Go to https://ownmy.app/settings/api-keys
|
|
173
|
+
2. Generate an API key
|
|
174
|
+
3. Paste it below
|
|
175
|
+
`);
|
|
176
|
+
|
|
177
|
+
const key = await ask('API key');
|
|
178
|
+
if (!key) { console.error(' No key provided.'); process.exit(1); }
|
|
179
|
+
|
|
180
|
+
process.stdout.write(' Verifying...');
|
|
181
|
+
try {
|
|
182
|
+
const result = await apiRequest('/cli/auth', { body: { api_key: key } });
|
|
183
|
+
if (result.success) {
|
|
184
|
+
const savedPath = saveApiKey(key);
|
|
185
|
+
console.log(` authenticated as ${result.email}`);
|
|
186
|
+
console.log(` Credentials saved to ${savedPath}\n`);
|
|
187
|
+
} else {
|
|
188
|
+
console.log(' invalid key.');
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (err.status === 401) {
|
|
193
|
+
console.log(' invalid key.');
|
|
194
|
+
console.error(' Check your key and try again.\n');
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function openBrowser(url) {
|
|
202
|
+
const platform = process.platform;
|
|
203
|
+
try {
|
|
204
|
+
if (platform === 'darwin') execSync(`open "${url}"`);
|
|
205
|
+
else if (platform === 'win32') execSync(`start "" "${url}"`);
|
|
206
|
+
else execSync(`xdg-open "${url}"`);
|
|
207
|
+
} catch {
|
|
208
|
+
// Silently fail — user can copy the URL from the console
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildCallbackHtml(state) {
|
|
213
|
+
return `<!DOCTYPE html>
|
|
214
|
+
<html lang="en">
|
|
215
|
+
<head>
|
|
216
|
+
<meta charset="UTF-8">
|
|
217
|
+
<title>Nometria - Signing In</title>
|
|
218
|
+
<style>
|
|
219
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
220
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
221
|
+
display: flex; align-items: center; justify-content: center;
|
|
222
|
+
min-height: 100vh; background: #faf9f7; color: #1a1a1a; }
|
|
223
|
+
.card { text-align: center; padding: 48px; max-width: 420px; }
|
|
224
|
+
h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
|
|
225
|
+
p { font-size: 14px; color: #666; line-height: 1.5; }
|
|
226
|
+
.spinner { width: 24px; height: 24px; border: 3px solid #e5e5e5;
|
|
227
|
+
border-top-color: #1a1a1a; border-radius: 50%;
|
|
228
|
+
animation: spin 0.8s linear infinite; margin: 16px auto; }
|
|
229
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
230
|
+
.success { display: none; }
|
|
231
|
+
.error { display: none; color: #dc2626; }
|
|
232
|
+
</style>
|
|
233
|
+
</head>
|
|
234
|
+
<body>
|
|
235
|
+
<div class="card">
|
|
236
|
+
<div id="loading">
|
|
237
|
+
<div class="spinner"></div>
|
|
238
|
+
<h1>Completing sign-in...</h1>
|
|
239
|
+
<p>Connecting your account to the CLI.</p>
|
|
240
|
+
</div>
|
|
241
|
+
<div id="success" class="success">
|
|
242
|
+
<h1>Signed in!</h1>
|
|
243
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
244
|
+
</div>
|
|
245
|
+
<div id="error" class="error">
|
|
246
|
+
<h1>Sign-in failed</h1>
|
|
247
|
+
<p id="error-msg">An unexpected error occurred.</p>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
<script>
|
|
251
|
+
(async () => {
|
|
252
|
+
try {
|
|
253
|
+
const hash = window.location.hash.substring(1);
|
|
254
|
+
const query = window.location.search.substring(1);
|
|
255
|
+
const params = new URLSearchParams(hash || query);
|
|
256
|
+
const token = params.get("access_token");
|
|
257
|
+
if (!token) throw new Error("No access token received");
|
|
258
|
+
const res = await fetch("/token", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { "Content-Type": "application/json" },
|
|
261
|
+
body: JSON.stringify({
|
|
262
|
+
access_token: token,
|
|
263
|
+
email: params.get("email"),
|
|
264
|
+
expires_at: params.get("expires_at"),
|
|
265
|
+
state: ${JSON.stringify(state)}
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
if (!res.ok) throw new Error("Failed to send token to CLI");
|
|
269
|
+
document.getElementById("loading").style.display = "none";
|
|
270
|
+
document.getElementById("success").style.display = "block";
|
|
271
|
+
} catch (e) {
|
|
272
|
+
document.getElementById("loading").style.display = "none";
|
|
273
|
+
document.getElementById("error").style.display = "block";
|
|
274
|
+
document.getElementById("error-msg").textContent = e.message;
|
|
275
|
+
}
|
|
276
|
+
})();
|
|
277
|
+
</script>
|
|
278
|
+
</body>
|
|
279
|
+
</html>`;
|
|
280
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nom logs — View deployment logs via Deno functions.
|
|
3
|
+
*/
|
|
4
|
+
import { readConfig } from '../lib/config.js';
|
|
5
|
+
import { requireApiKey } from '../lib/auth.js';
|
|
6
|
+
import { apiRequest } from '../lib/api.js';
|
|
7
|
+
|
|
8
|
+
export async function logs(flags) {
|
|
9
|
+
const apiKey = requireApiKey();
|
|
10
|
+
const config = readConfig();
|
|
11
|
+
const appId = config.app_id || config.name;
|
|
12
|
+
|
|
13
|
+
if (flags.follow) {
|
|
14
|
+
console.log(` Streaming logs for ${appId} (Ctrl+C to stop)\n`);
|
|
15
|
+
while (true) {
|
|
16
|
+
try {
|
|
17
|
+
const result = await apiRequest('/cli/logs', {
|
|
18
|
+
apiKey,
|
|
19
|
+
body: { app_id: appId },
|
|
20
|
+
});
|
|
21
|
+
if (result.lines?.length) {
|
|
22
|
+
for (const line of result.lines) {
|
|
23
|
+
console.log(line);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} catch { /* transient errors — keep polling */ }
|
|
27
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
const result = await apiRequest('/cli/logs', {
|
|
31
|
+
apiKey,
|
|
32
|
+
body: { app_id: appId },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (flags.json) {
|
|
36
|
+
console.log(JSON.stringify(result, null, 2));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!result.lines?.length) {
|
|
41
|
+
console.log(`\n No logs available for ${appId}\n`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const line of result.lines) {
|
|
46
|
+
console.log(line);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|