@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,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nom start — Start a stopped instance 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 start(flags) {
|
|
9
|
+
const apiKey = requireApiKey();
|
|
10
|
+
const config = readConfig();
|
|
11
|
+
const appId = config.app_id || config.name;
|
|
12
|
+
|
|
13
|
+
const result = await apiRequest('/updateInstanceState', {
|
|
14
|
+
apiKey,
|
|
15
|
+
body: { app_id: appId, instance_state: 'start' },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (flags.json) {
|
|
19
|
+
console.log(JSON.stringify(result, null, 2));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log(`\n Instance starting...`);
|
|
24
|
+
if (result.url) console.log(` URL: ${result.url}`);
|
|
25
|
+
console.log();
|
|
26
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nom status — Check deployment status 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 status(flags) {
|
|
9
|
+
const apiKey = requireApiKey();
|
|
10
|
+
const config = readConfig();
|
|
11
|
+
const appId = config.app_id || config.name;
|
|
12
|
+
|
|
13
|
+
const result = await apiRequest('/checkAwsStatus', {
|
|
14
|
+
apiKey,
|
|
15
|
+
body: { app_id: appId },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (flags.json) {
|
|
19
|
+
console.log(JSON.stringify(result, null, 2));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const data = result.data || {};
|
|
24
|
+
console.log(`
|
|
25
|
+
App: ${config.name || appId}
|
|
26
|
+
Status: ${data.deploymentStatus || data.instanceState || result.status || 'unknown'}
|
|
27
|
+
URL: ${data.deployUrl || '—'}
|
|
28
|
+
Platform: ${config.platform}
|
|
29
|
+
Region: ${config.region}
|
|
30
|
+
Instance: ${data.instanceType || config.instanceType || '—'}
|
|
31
|
+
IP: ${data.ipAddress || '—'}
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nom stop — Stop a running instance 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 stop(flags) {
|
|
9
|
+
const apiKey = requireApiKey();
|
|
10
|
+
const config = readConfig();
|
|
11
|
+
const appId = config.app_id || config.name;
|
|
12
|
+
|
|
13
|
+
const result = await apiRequest('/updateInstanceState', {
|
|
14
|
+
apiKey,
|
|
15
|
+
body: { app_id: appId, instance_state: 'stop' },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (flags.json) {
|
|
19
|
+
console.log(JSON.stringify(result, null, 2));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log(`\n Instance stopped.\n`);
|
|
24
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nom terminate — Terminate (destroy) an instance 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
|
+
import { confirm } from '../lib/prompt.js';
|
|
8
|
+
|
|
9
|
+
export async function terminate(flags) {
|
|
10
|
+
const apiKey = requireApiKey();
|
|
11
|
+
const config = readConfig();
|
|
12
|
+
const appId = config.app_id || config.name;
|
|
13
|
+
|
|
14
|
+
if (!flags.yes && !flags.y) {
|
|
15
|
+
const ok = await confirm(`Terminate instance "${appId}"? This cannot be undone.`, false);
|
|
16
|
+
if (!ok) {
|
|
17
|
+
console.log('\n Aborted.\n');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const result = await apiRequest('/updateInstanceState', {
|
|
23
|
+
apiKey,
|
|
24
|
+
body: { app_id: appId, instance_state: 'terminate' },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (flags.json) {
|
|
28
|
+
console.log(JSON.stringify(result, null, 2));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(`\n Instance terminated.\n`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nom upgrade — Upgrade instance size 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
|
+
const VALID_SIZES = ['2gb', '4gb', '8gb', '16gb'];
|
|
9
|
+
|
|
10
|
+
export async function upgrade(flags, positionals) {
|
|
11
|
+
const apiKey = requireApiKey();
|
|
12
|
+
const config = readConfig();
|
|
13
|
+
const appId = config.app_id || config.name;
|
|
14
|
+
|
|
15
|
+
const instanceType = positionals[0];
|
|
16
|
+
if (!instanceType) {
|
|
17
|
+
console.log(`
|
|
18
|
+
Usage: nom upgrade <size>
|
|
19
|
+
|
|
20
|
+
Sizes: ${VALID_SIZES.join(', ')}
|
|
21
|
+
`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const size = instanceType.toLowerCase();
|
|
26
|
+
if (!VALID_SIZES.includes(size)) {
|
|
27
|
+
console.error(`\n Invalid size "${instanceType}". Must be one of: ${VALID_SIZES.join(', ')}\n`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = await apiRequest('/upgradeInstance', {
|
|
32
|
+
apiKey,
|
|
33
|
+
body: { app_id: appId, instance_type: size },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (flags.json) {
|
|
37
|
+
console.log(JSON.stringify(result, null, 2));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (result.success) {
|
|
42
|
+
console.log(`\n Upgraded to ${size}`);
|
|
43
|
+
if (result.message) console.log(` ${result.message}`);
|
|
44
|
+
console.log();
|
|
45
|
+
} else {
|
|
46
|
+
console.error(`\n Upgrade failed: ${result.error || 'Unknown error'}\n`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nom whoami — Show current authenticated user via Deno functions.
|
|
3
|
+
*/
|
|
4
|
+
import { requireApiKey } from '../lib/auth.js';
|
|
5
|
+
import { apiRequest } from '../lib/api.js';
|
|
6
|
+
|
|
7
|
+
export async function whoami(flags) {
|
|
8
|
+
const apiKey = requireApiKey();
|
|
9
|
+
const result = await apiRequest('/cli/auth', {
|
|
10
|
+
body: { api_key: apiKey },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
if (flags.json) {
|
|
14
|
+
console.log(JSON.stringify(result, null, 2));
|
|
15
|
+
} else {
|
|
16
|
+
console.log(`\n Logged in as: ${result.email}\n`);
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/lib/api.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the Deno function endpoints at app.ownmy.app.
|
|
3
|
+
* All CLI calls go through the Deno function layer, NOT the Python backend.
|
|
4
|
+
* Uses native fetch (Node 18+).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DEFAULT_API_URL = 'https://app.ownmy.app';
|
|
8
|
+
|
|
9
|
+
function getBaseUrl() {
|
|
10
|
+
return process.env.NOMETRIA_API_URL || DEFAULT_API_URL;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Make a JSON POST request to a Deno function endpoint.
|
|
15
|
+
* The API key is included in the request body (not as a Bearer token),
|
|
16
|
+
* because CLI functions use body-based auth via cli_api_keys table.
|
|
17
|
+
*/
|
|
18
|
+
export async function apiRequest(path, { method = 'POST', body = {}, apiKey } = {}) {
|
|
19
|
+
const url = `${getBaseUrl()}${path}`;
|
|
20
|
+
|
|
21
|
+
// Include api_key in the body for CLI auth
|
|
22
|
+
const payload = apiKey ? { ...body, api_key: apiKey } : body;
|
|
23
|
+
|
|
24
|
+
const res = await fetch(url, {
|
|
25
|
+
method,
|
|
26
|
+
headers: {
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
'User-Agent': 'nom-cli',
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify(payload),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
let message = `API error: ${res.status}`;
|
|
35
|
+
try {
|
|
36
|
+
const raw = await res.json();
|
|
37
|
+
// server.js wraps errors in { data: { error: ... } }
|
|
38
|
+
const data = raw?.data || raw;
|
|
39
|
+
message = data.detail || data.message || data.error || message;
|
|
40
|
+
} catch { /* ignore parse errors */ }
|
|
41
|
+
const err = new Error(message);
|
|
42
|
+
err.status = res.status;
|
|
43
|
+
if (res.status === 401) err.code = 'ERR_AUTH';
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// server.js wraps all JSON responses in { data: ... } — unwrap automatically
|
|
48
|
+
const raw = await res.json();
|
|
49
|
+
return raw?.data !== undefined ? raw.data : raw;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Upload a file to the Deno /cli/upload endpoint using multipart form data.
|
|
54
|
+
* Returns the public URL of the uploaded file.
|
|
55
|
+
*/
|
|
56
|
+
export async function uploadFile(apiKey, fileBuffer, fileName = 'code.tar.gz') {
|
|
57
|
+
const url = `${getBaseUrl()}/cli/upload`;
|
|
58
|
+
|
|
59
|
+
// Use FormData for multipart upload
|
|
60
|
+
const { FormData, Blob } = globalThis;
|
|
61
|
+
const formData = new FormData();
|
|
62
|
+
formData.append('api_key', apiKey);
|
|
63
|
+
formData.append('file', new Blob([fileBuffer], { type: 'application/gzip' }), fileName);
|
|
64
|
+
|
|
65
|
+
const res = await fetch(url, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'User-Agent': 'nom-cli' },
|
|
68
|
+
body: formData,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
let message = `Upload failed: ${res.status}`;
|
|
73
|
+
try {
|
|
74
|
+
const raw = await res.json();
|
|
75
|
+
const data = raw?.data || raw;
|
|
76
|
+
message = data.error || message;
|
|
77
|
+
} catch { /* ignore */ }
|
|
78
|
+
throw new Error(message);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const raw = await res.json();
|
|
82
|
+
return raw?.data !== undefined ? raw.data : raw;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { getBaseUrl };
|
package/src/lib/auth.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API key resolution: NOMETRIA_API_KEY env > ~/.nometria/credentials.json
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
const CREDENTIALS_DIR = join(homedir(), '.nometria');
|
|
9
|
+
const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'credentials.json');
|
|
10
|
+
|
|
11
|
+
export function getApiKey() {
|
|
12
|
+
// Env var takes priority (API key format)
|
|
13
|
+
if (process.env.NOMETRIA_API_KEY) {
|
|
14
|
+
return process.env.NOMETRIA_API_KEY;
|
|
15
|
+
}
|
|
16
|
+
// Also accept JWT token from VS Code extension / Claude Code commands
|
|
17
|
+
if (process.env.NOMETRIA_TOKEN) {
|
|
18
|
+
return process.env.NOMETRIA_TOKEN;
|
|
19
|
+
}
|
|
20
|
+
// Fall back to stored credentials
|
|
21
|
+
if (existsSync(CREDENTIALS_FILE)) {
|
|
22
|
+
try {
|
|
23
|
+
const creds = JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf8'));
|
|
24
|
+
if (creds.apiKey) return creds.apiKey;
|
|
25
|
+
} catch { /* ignore malformed file */ }
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function requireApiKey() {
|
|
31
|
+
const key = getApiKey();
|
|
32
|
+
if (!key) {
|
|
33
|
+
const err = new Error('Not authenticated');
|
|
34
|
+
err.code = 'ERR_AUTH';
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
return key;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function saveApiKey(apiKey) {
|
|
41
|
+
if (!existsSync(CREDENTIALS_DIR)) {
|
|
42
|
+
mkdirSync(CREDENTIALS_DIR, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify({ apiKey }, null, 2) + '\n', {
|
|
45
|
+
mode: 0o600, // owner-only read/write
|
|
46
|
+
});
|
|
47
|
+
return CREDENTIALS_FILE;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function clearApiKey() {
|
|
51
|
+
if (existsSync(CREDENTIALS_FILE)) {
|
|
52
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify({}, null, 2) + '\n', {
|
|
53
|
+
mode: 0o600,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read and validate nometria.json config
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILE = 'nometria.json';
|
|
8
|
+
|
|
9
|
+
const VALID_PLATFORMS = ['aws', 'gcp', 'azure', 'digitalocean', 'hetzner', 'vercel', 'render'];
|
|
10
|
+
const VALID_FRAMEWORKS = ['vite', 'nextjs', 'remix', 'static', 'node', 'deno'];
|
|
11
|
+
const VALID_INSTANCE_TYPES = ['2gb', '4gb', '8gb', '16gb', '32gb'];
|
|
12
|
+
|
|
13
|
+
export function readConfig(dir = process.cwd()) {
|
|
14
|
+
const configPath = join(dir, CONFIG_FILE);
|
|
15
|
+
if (!existsSync(configPath)) {
|
|
16
|
+
const err = new Error(`${CONFIG_FILE} not found in ${dir}`);
|
|
17
|
+
err.code = 'ERR_CONFIG';
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
21
|
+
const config = JSON.parse(raw);
|
|
22
|
+
return validate(config);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function configExists(dir = process.cwd()) {
|
|
26
|
+
return existsSync(join(dir, CONFIG_FILE));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validate(config) {
|
|
30
|
+
// "name" is required for CLI deploys; "app_id" alone is enough for linked apps
|
|
31
|
+
if (!config.name && !config.app_id) {
|
|
32
|
+
throw new Error('nometria.json: either "name" or "app_id" is required');
|
|
33
|
+
}
|
|
34
|
+
if (config.name) {
|
|
35
|
+
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(config.name) && config.name.length > 1) {
|
|
36
|
+
if (!/^[a-z0-9-]+$/.test(config.name)) {
|
|
37
|
+
throw new Error('nometria.json: "name" must be lowercase alphanumeric with hyphens');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
config.platform = config.platform || 'aws';
|
|
43
|
+
if (!VALID_PLATFORMS.includes(config.platform)) {
|
|
44
|
+
throw new Error(`nometria.json: "platform" must be one of: ${VALID_PLATFORMS.join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (config.framework && !VALID_FRAMEWORKS.includes(config.framework)) {
|
|
48
|
+
throw new Error(`nometria.json: "framework" must be one of: ${VALID_FRAMEWORKS.join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
config.instanceType = config.instanceType || '4gb';
|
|
52
|
+
if (!VALID_INSTANCE_TYPES.includes(config.instanceType)) {
|
|
53
|
+
throw new Error(`nometria.json: "instanceType" must be one of: ${VALID_INSTANCE_TYPES.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
config.region = config.region || 'us-east-1';
|
|
57
|
+
config.ignore = config.ignore || [];
|
|
58
|
+
config.build = config.build || {};
|
|
59
|
+
config.env = config.env || {};
|
|
60
|
+
|
|
61
|
+
// Optional fields from IDE extension linking
|
|
62
|
+
// app_id, migration_id, api_url are preserved but not required for CLI init
|
|
63
|
+
return config;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function updateConfig(dir, updates) {
|
|
67
|
+
const configPath = join(dir, CONFIG_FILE);
|
|
68
|
+
let config = {};
|
|
69
|
+
if (existsSync(configPath)) {
|
|
70
|
+
config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
Object.assign(config, updates);
|
|
73
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveEnv(envConfig) {
|
|
77
|
+
const resolved = {};
|
|
78
|
+
for (const [key, value] of Object.entries(envConfig)) {
|
|
79
|
+
if (typeof value === 'string' && value.startsWith('@env:')) {
|
|
80
|
+
const envVar = value.slice(5);
|
|
81
|
+
const envValue = process.env[envVar];
|
|
82
|
+
if (!envValue) {
|
|
83
|
+
throw new Error(`Environment variable ${envVar} is not set (referenced by ${key})`);
|
|
84
|
+
}
|
|
85
|
+
resolved[key] = envValue;
|
|
86
|
+
} else {
|
|
87
|
+
resolved[key] = value;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return resolved;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { CONFIG_FILE, VALID_PLATFORMS, VALID_FRAMEWORKS, VALID_INSTANCE_TYPES };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-detect project framework and build settings.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join, basename } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const DETECTORS = [
|
|
8
|
+
{
|
|
9
|
+
framework: 'nextjs',
|
|
10
|
+
files: ['next.config.js', 'next.config.mjs', 'next.config.ts'],
|
|
11
|
+
build: { command: 'npm run build', output: '.next' },
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
framework: 'remix',
|
|
15
|
+
files: ['remix.config.js', 'remix.config.ts'],
|
|
16
|
+
build: { command: 'npm run build', output: 'build' },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
framework: 'vite',
|
|
20
|
+
files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'],
|
|
21
|
+
build: { command: 'npm run build', output: 'dist' },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
framework: 'static',
|
|
25
|
+
files: ['index.html'],
|
|
26
|
+
build: { command: null, output: '.' },
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function detectFramework(dir = process.cwd()) {
|
|
31
|
+
// Check package.json scripts for hints
|
|
32
|
+
const pkgPath = join(dir, 'package.json');
|
|
33
|
+
let pkg = null;
|
|
34
|
+
if (existsSync(pkgPath)) {
|
|
35
|
+
try {
|
|
36
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
37
|
+
} catch { /* ignore */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check for framework config files
|
|
41
|
+
for (const detector of DETECTORS) {
|
|
42
|
+
for (const file of detector.files) {
|
|
43
|
+
if (existsSync(join(dir, file))) {
|
|
44
|
+
return {
|
|
45
|
+
framework: detector.framework,
|
|
46
|
+
build: { ...detector.build },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check package.json dependencies
|
|
53
|
+
if (pkg) {
|
|
54
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
55
|
+
if (deps['next']) return { framework: 'nextjs', build: { command: 'npm run build', output: '.next' } };
|
|
56
|
+
if (deps['@remix-run/node']) return { framework: 'remix', build: { command: 'npm run build', output: 'build' } };
|
|
57
|
+
if (deps['vite']) return { framework: 'vite', build: { command: 'npm run build', output: 'dist' } };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check if it looks like a Node.js project
|
|
61
|
+
if (pkg && (pkg.main || pkg.scripts?.start)) {
|
|
62
|
+
return { framework: 'node', build: { command: null, output: '.' } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { framework: 'static', build: { command: null, output: '.' } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function detectPackageManager(dir = process.cwd()) {
|
|
69
|
+
if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
70
|
+
if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
|
|
71
|
+
if (existsSync(join(dir, 'bun.lockb'))) return 'bun';
|
|
72
|
+
return 'npm';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getProjectName(dir = process.cwd()) {
|
|
76
|
+
const pkgPath = join(dir, 'package.json');
|
|
77
|
+
if (existsSync(pkgPath)) {
|
|
78
|
+
try {
|
|
79
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
80
|
+
if (pkg.name) {
|
|
81
|
+
// Strip scope and sanitize
|
|
82
|
+
return pkg.name.replace(/^@[^/]+\//, '').replace(/[^a-z0-9-]/g, '-');
|
|
83
|
+
}
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
}
|
|
86
|
+
// Use directory name
|
|
87
|
+
return basename(dir).replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
|
88
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive prompts using native readline.
|
|
3
|
+
*/
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
|
|
6
|
+
function createReadline() {
|
|
7
|
+
return createInterface({
|
|
8
|
+
input: process.stdin,
|
|
9
|
+
output: process.stdout,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ask(question, defaultValue = '') {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const rl = createReadline();
|
|
16
|
+
const suffix = defaultValue ? ` (${defaultValue})` : '';
|
|
17
|
+
rl.question(` ${question}${suffix}: `, (answer) => {
|
|
18
|
+
rl.close();
|
|
19
|
+
resolve(answer.trim() || defaultValue);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function askSecret(question) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const rl = createReadline();
|
|
27
|
+
// Mute output for secret input
|
|
28
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
29
|
+
rl.question(` ${question}: `, (answer) => {
|
|
30
|
+
process.stdout.write = originalWrite;
|
|
31
|
+
console.log(); // newline after hidden input
|
|
32
|
+
rl.close();
|
|
33
|
+
resolve(answer.trim());
|
|
34
|
+
});
|
|
35
|
+
// Hide typed characters
|
|
36
|
+
process.stdout.write = (chunk) => {
|
|
37
|
+
if (typeof chunk === 'string' && !chunk.includes(question)) {
|
|
38
|
+
return originalWrite('*');
|
|
39
|
+
}
|
|
40
|
+
return originalWrite(chunk);
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function choose(question, options, defaultIndex = 0) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
console.log(`\n ${question}\n`);
|
|
48
|
+
options.forEach((opt, i) => {
|
|
49
|
+
const marker = i === defaultIndex ? '>' : ' ';
|
|
50
|
+
console.log(` ${marker} ${i + 1}. ${opt}`);
|
|
51
|
+
});
|
|
52
|
+
const rl = createReadline();
|
|
53
|
+
rl.question(`\n Choose [${defaultIndex + 1}]: `, (answer) => {
|
|
54
|
+
rl.close();
|
|
55
|
+
const idx = answer.trim() ? parseInt(answer) - 1 : defaultIndex;
|
|
56
|
+
if (idx >= 0 && idx < options.length) {
|
|
57
|
+
resolve(options[idx]);
|
|
58
|
+
} else {
|
|
59
|
+
resolve(options[defaultIndex]);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function confirm(question, defaultYes = true) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const rl = createReadline();
|
|
68
|
+
const hint = defaultYes ? 'Y/n' : 'y/N';
|
|
69
|
+
rl.question(` ${question} (${hint}): `, (answer) => {
|
|
70
|
+
rl.close();
|
|
71
|
+
const a = answer.trim().toLowerCase();
|
|
72
|
+
if (!a) return resolve(defaultYes);
|
|
73
|
+
resolve(a === 'y' || a === 'yes');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple terminal spinner — zero dependencies.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const FRAMES = [' ', '. ', '.. ', '...'];
|
|
6
|
+
|
|
7
|
+
export function createSpinner(message) {
|
|
8
|
+
let frameIndex = 0;
|
|
9
|
+
let interval = null;
|
|
10
|
+
let currentMessage = message;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
start() {
|
|
14
|
+
process.stdout.write(` ${currentMessage} `);
|
|
15
|
+
interval = setInterval(() => {
|
|
16
|
+
frameIndex = (frameIndex + 1) % FRAMES.length;
|
|
17
|
+
process.stdout.write(`\r ${currentMessage} ${FRAMES[frameIndex]}`);
|
|
18
|
+
}, 200);
|
|
19
|
+
return this;
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
update(msg) {
|
|
23
|
+
currentMessage = msg;
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
succeed(msg) {
|
|
27
|
+
clearInterval(interval);
|
|
28
|
+
process.stdout.write(`\r ${msg || currentMessage} \n`);
|
|
29
|
+
return this;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
fail(msg) {
|
|
33
|
+
clearInterval(interval);
|
|
34
|
+
process.stdout.write(`\r ${msg || currentMessage} \n`);
|
|
35
|
+
return this;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
stop() {
|
|
39
|
+
clearInterval(interval);
|
|
40
|
+
process.stdout.write('\n');
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/lib/tar.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create tar.gz archive from project directory.
|
|
3
|
+
* Uses system `tar` command (available on macOS/Linux/WSL).
|
|
4
|
+
*/
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { mkdtempSync, statSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_EXCLUDES = [
|
|
11
|
+
'node_modules',
|
|
12
|
+
'.git',
|
|
13
|
+
'.env',
|
|
14
|
+
'.env.*',
|
|
15
|
+
'.DS_Store',
|
|
16
|
+
'*.log',
|
|
17
|
+
'.next/cache',
|
|
18
|
+
'.turbo',
|
|
19
|
+
'.vercel',
|
|
20
|
+
'.nometria',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function createTarball(dir, extraIgnore = []) {
|
|
24
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'nom-'));
|
|
25
|
+
const tarPath = join(tmpDir, 'code.tar.gz');
|
|
26
|
+
|
|
27
|
+
const excludes = [...DEFAULT_EXCLUDES, ...extraIgnore]
|
|
28
|
+
.map(p => `--exclude='${p}'`)
|
|
29
|
+
.join(' ');
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
execSync(`tar czf "${tarPath}" ${excludes} -C "${dir}" .`, {
|
|
33
|
+
stdio: 'pipe',
|
|
34
|
+
maxBuffer: 100 * 1024 * 1024, // 100MB
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new Error(`Failed to create archive: ${err.stderr?.toString() || err.message}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const stats = statSync(tarPath);
|
|
41
|
+
const buffer = readFileSync(tarPath);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
path: tarPath,
|
|
45
|
+
buffer,
|
|
46
|
+
size: stats.size,
|
|
47
|
+
sizeFormatted: formatBytes(stats.size),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatBytes(bytes) {
|
|
52
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
53
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
54
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
55
|
+
}
|