@nometria-ai/nom 0.2.9 → 0.3.1
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/package.json +1 -1
- package/src/cli.js +43 -3
- package/src/commands/cron.js +162 -0
- package/src/commands/db.js +267 -0
- package/src/commands/deploy.js +154 -22
- package/src/commands/env.js +159 -10
- package/src/commands/init.js +50 -2
- package/src/commands/list.js +51 -0
- package/src/commands/rollback.js +95 -0
- package/src/commands/setup.js +145 -1
- package/src/commands/ssh.js +88 -0
- package/src/lib/cache.js +56 -0
- package/src/lib/detect.js +107 -1
- package/src/lib/telemetry.js +123 -0
package/src/commands/deploy.js
CHANGED
|
@@ -12,9 +12,20 @@ import { apiRequest, uploadFile } from '../lib/api.js';
|
|
|
12
12
|
import { createTarball } from '../lib/tar.js';
|
|
13
13
|
import { createSpinner } from '../lib/spinner.js';
|
|
14
14
|
import { confirm } from '../lib/prompt.js';
|
|
15
|
+
import { trackCommand } from '../lib/telemetry.js';
|
|
15
16
|
import { login } from './login.js';
|
|
16
17
|
import { init } from './init.js';
|
|
17
18
|
|
|
19
|
+
const INSTANCE_COSTS = {
|
|
20
|
+
'2gb': { monthly: '$39', type: 't4g.small' },
|
|
21
|
+
'4gb': { monthly: '$49', type: 't4g.medium' },
|
|
22
|
+
'8gb': { monthly: '$79', type: 't4g.large' },
|
|
23
|
+
'16gb': { monthly: '$129', type: 't4g.xlarge' },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const HELP_URL = 'https://docs.nometria.com';
|
|
27
|
+
const DASHBOARD_URL = 'https://nometria.com';
|
|
28
|
+
|
|
18
29
|
export async function deploy(flags) {
|
|
19
30
|
// Auto-login if not authenticated
|
|
20
31
|
let apiKey = getApiKey();
|
|
@@ -39,6 +50,13 @@ export async function deploy(flags) {
|
|
|
39
50
|
const envVars = resolveEnv(config.env);
|
|
40
51
|
const appName = config.name || config.app_id;
|
|
41
52
|
const isResync = !!config.app_id;
|
|
53
|
+
const isDryRun = flags['dry-run'] || flags.dryRun;
|
|
54
|
+
|
|
55
|
+
const telemetry = trackCommand('deploy', { framework: config.framework, platform: config.platform, isResync });
|
|
56
|
+
|
|
57
|
+
if (isDryRun) {
|
|
58
|
+
return dryRun(config, apiKey, appName);
|
|
59
|
+
}
|
|
42
60
|
|
|
43
61
|
// Auto-detect services if not in config (so nometria.json includes it in tarball)
|
|
44
62
|
if (!config.services) {
|
|
@@ -51,15 +69,26 @@ export async function deploy(flags) {
|
|
|
51
69
|
}
|
|
52
70
|
}
|
|
53
71
|
|
|
72
|
+
const instanceSize = config.instanceType || '4gb';
|
|
73
|
+
const cost = INSTANCE_COSTS[instanceSize];
|
|
74
|
+
|
|
54
75
|
if (isResync) {
|
|
55
76
|
console.log(`\n Resyncing ${appName} on ${config.platform} (${config.region})\n`);
|
|
56
77
|
} else {
|
|
57
|
-
console.log(`\n Deploying ${appName} to ${config.platform} (${config.region})
|
|
78
|
+
console.log(`\n Deploying ${appName} to ${config.platform} (${config.region})`);
|
|
79
|
+
if (cost) {
|
|
80
|
+
console.log(` Instance: ${instanceSize} (${cost.type}) — ${cost.monthly}/mo`);
|
|
81
|
+
}
|
|
82
|
+
console.log();
|
|
58
83
|
}
|
|
59
84
|
|
|
85
|
+
const totalSteps = config.build?.command ? 5 : 4;
|
|
86
|
+
let step = 0;
|
|
87
|
+
const stepLabel = (label) => `[${++step}/${totalSteps}] ${label}`;
|
|
88
|
+
|
|
60
89
|
// Step 1: Build
|
|
61
90
|
if (config.build?.command) {
|
|
62
|
-
const spinner = createSpinner('Building').start();
|
|
91
|
+
const spinner = createSpinner(stepLabel('Building')).start();
|
|
63
92
|
try {
|
|
64
93
|
execSync(config.build.command, {
|
|
65
94
|
cwd: process.cwd(),
|
|
@@ -75,7 +104,7 @@ export async function deploy(flags) {
|
|
|
75
104
|
}
|
|
76
105
|
|
|
77
106
|
// Step 2: Create archive
|
|
78
|
-
const archiveSpinner = createSpinner('Creating archive').start();
|
|
107
|
+
const archiveSpinner = createSpinner(stepLabel('Creating archive')).start();
|
|
79
108
|
let tarball;
|
|
80
109
|
try {
|
|
81
110
|
tarball = createTarball(process.cwd(), config.ignore);
|
|
@@ -85,8 +114,8 @@ export async function deploy(flags) {
|
|
|
85
114
|
throw err;
|
|
86
115
|
}
|
|
87
116
|
|
|
88
|
-
// Step 3: Upload
|
|
89
|
-
const uploadSpinner = createSpinner('Uploading').start();
|
|
117
|
+
// Step 3: Upload
|
|
118
|
+
const uploadSpinner = createSpinner(stepLabel('Uploading')).start();
|
|
90
119
|
let uploadResult;
|
|
91
120
|
try {
|
|
92
121
|
uploadResult = await uploadFile(apiKey, tarball.buffer, `${appName}.tar.gz`);
|
|
@@ -96,8 +125,8 @@ export async function deploy(flags) {
|
|
|
96
125
|
throw err;
|
|
97
126
|
}
|
|
98
127
|
|
|
99
|
-
// Step 4: Trigger deploy
|
|
100
|
-
const deploySpinner = createSpinner(isResync ? 'Resyncing' : 'Deploying').start();
|
|
128
|
+
// Step 4: Trigger deploy
|
|
129
|
+
const deploySpinner = createSpinner(stepLabel(isResync ? 'Resyncing' : 'Deploying')).start();
|
|
101
130
|
let deployResult;
|
|
102
131
|
try {
|
|
103
132
|
deployResult = await apiRequest('/cli/deploy', {
|
|
@@ -118,13 +147,14 @@ export async function deploy(flags) {
|
|
|
118
147
|
throw err;
|
|
119
148
|
}
|
|
120
149
|
|
|
121
|
-
// Step 5: Poll for status
|
|
150
|
+
// Step 5: Poll for status
|
|
122
151
|
const deployId = deployResult.deploy_id || appName;
|
|
123
|
-
const dashboardUrl =
|
|
152
|
+
const dashboardUrl = `${DASHBOARD_URL}/AppDetails?app_id=${deployId}`;
|
|
124
153
|
console.log(`\n Dashboard: ${dashboardUrl}`);
|
|
125
154
|
console.log(` You can close this terminal — check the dashboard for progress.\n`);
|
|
126
155
|
|
|
127
156
|
let finalStatus;
|
|
157
|
+
let consecutiveErrors = 0;
|
|
128
158
|
const pollStart = Date.now();
|
|
129
159
|
const POLL_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
130
160
|
while (true) {
|
|
@@ -134,37 +164,56 @@ export async function deploy(flags) {
|
|
|
134
164
|
apiKey,
|
|
135
165
|
body: { app_id: deployId },
|
|
136
166
|
});
|
|
137
|
-
|
|
138
|
-
|
|
167
|
+
consecutiveErrors = 0; // reset on success
|
|
168
|
+
|
|
169
|
+
// statusResult may be unwrapped (top-level fields) or nested under .data
|
|
170
|
+
const deployStatus = statusResult.deploymentStatus || statusResult.data?.deploymentStatus;
|
|
171
|
+
const instanceState = statusResult.instanceState || statusResult.data?.instanceState;
|
|
139
172
|
const topStatus = statusResult.status;
|
|
140
|
-
// Display status: prefer deploymentStatus (accurate), fall back to instanceState
|
|
141
173
|
const st = deployStatus || instanceState || topStatus || 'unknown';
|
|
142
174
|
deploySpinner.update(`${isResync ? 'Resyncing' : 'Deploying'} — ${st}`);
|
|
143
175
|
|
|
144
|
-
// Only trust deploymentStatus for completion — instanceState='running' just means
|
|
145
|
-
// EC2 booted, and topStatus='deployed' just means the record exists in DB.
|
|
146
|
-
// The sync script calls report_status("running") only after everything finishes.
|
|
147
176
|
const isDone = deployStatus === 'completed' || deployStatus === 'running';
|
|
148
177
|
if (isDone) {
|
|
149
178
|
finalStatus = statusResult;
|
|
150
179
|
deploySpinner.succeed(isResync ? 'Resynced successfully' : 'Deployed successfully');
|
|
180
|
+
telemetry.success({ instanceType: instanceSize });
|
|
151
181
|
break;
|
|
152
182
|
}
|
|
153
183
|
if (deployStatus === 'failed' || st === 'failed') {
|
|
154
|
-
deploySpinner.fail(`${isResync ? 'Resync' : 'Deploy'} failed: ${statusResult.data?.errorMessage || 'unknown error'}`);
|
|
184
|
+
deploySpinner.fail(`${isResync ? 'Resync' : 'Deploy'} failed: ${statusResult.errorMessage || statusResult.data?.errorMessage || 'unknown error'}`);
|
|
185
|
+
console.error(`\n View logs: nom logs`);
|
|
186
|
+
console.error(` Dashboard: ${dashboardUrl}`);
|
|
187
|
+
console.error(` Help: ${HELP_URL}/deploy/overview\n`);
|
|
155
188
|
process.exit(1);
|
|
156
189
|
}
|
|
157
190
|
|
|
158
|
-
// Timeout: if instance is running but status stuck at deploying, show URL and exit
|
|
191
|
+
// Timeout: if instance is running but status stuck at deploying, show URL and exit gracefully
|
|
159
192
|
if (Date.now() - pollStart > POLL_TIMEOUT_MS && instanceState === 'running') {
|
|
160
193
|
finalStatus = statusResult;
|
|
161
194
|
deploySpinner.succeed('Deploy in progress — check dashboard for final status');
|
|
162
195
|
break;
|
|
163
196
|
}
|
|
164
|
-
} catch {
|
|
165
|
-
|
|
197
|
+
} catch (err) {
|
|
198
|
+
consecutiveErrors++;
|
|
199
|
+
if (consecutiveErrors >= 5) {
|
|
200
|
+
deploySpinner.fail(`Lost connection while polling (${consecutiveErrors} consecutive errors)`);
|
|
201
|
+
console.error(`\n Last error: ${err.message}`);
|
|
202
|
+
console.error(` Check status manually: nom status`);
|
|
203
|
+
console.error(` Dashboard: ${dashboardUrl}`);
|
|
204
|
+
console.error(` Help: ${HELP_URL}/deploy/overview\n`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
deploySpinner.update(`${isResync ? 'Resyncing' : 'Deploying'} — retrying (${consecutiveErrors}/5)...`);
|
|
166
208
|
}
|
|
167
209
|
}
|
|
210
|
+
if (!finalStatus) {
|
|
211
|
+
deploySpinner.fail('Timed out waiting for deployment');
|
|
212
|
+
console.error(`\n The deploy may still be in progress. Check status:`);
|
|
213
|
+
console.error(` nom status`);
|
|
214
|
+
console.error(` Dashboard: ${dashboardUrl}\n`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
168
217
|
|
|
169
218
|
// Step 6: Write app_id and migration_id back to nometria.json
|
|
170
219
|
const updates = {};
|
|
@@ -175,12 +224,27 @@ export async function deploy(flags) {
|
|
|
175
224
|
}
|
|
176
225
|
|
|
177
226
|
// Step 7: Print result
|
|
178
|
-
const url = finalStatus.data?.deployUrl || finalStatus.url || `https://${deployId}.ownmy.app`;
|
|
227
|
+
const url = finalStatus.deployUrl || finalStatus.data?.deployUrl || finalStatus.url || `https://${deployId}.ownmy.app`;
|
|
228
|
+
const instanceInfo = finalStatus.instanceType || finalStatus.data?.instanceType || instanceSize;
|
|
179
229
|
console.log(`
|
|
180
|
-
|
|
181
|
-
|
|
230
|
+
${isResync ? 'Resynced' : 'Deployed'} successfully!
|
|
231
|
+
|
|
232
|
+
URL: ${url}
|
|
233
|
+
Instance: ${instanceInfo}${cost ? ` (${cost.monthly}/mo)` : ''}
|
|
234
|
+
Dashboard: ${dashboardUrl}
|
|
182
235
|
`);
|
|
183
236
|
|
|
237
|
+
// First deploy — show next steps
|
|
238
|
+
if (!isResync) {
|
|
239
|
+
console.log(` Next steps:`);
|
|
240
|
+
console.log(` nom github connect Connect GitHub for auto-deploy`);
|
|
241
|
+
console.log(` nom domain add <url> Add a custom domain`);
|
|
242
|
+
console.log(` nom env set KEY=VAL Set environment variables`);
|
|
243
|
+
console.log(` nom logs -f Watch live logs`);
|
|
244
|
+
console.log(` nom scan Run security scan`);
|
|
245
|
+
console.log();
|
|
246
|
+
}
|
|
247
|
+
|
|
184
248
|
// Step 8: Auto-detect git repo and offer GitHub connection
|
|
185
249
|
if (!flags.yes) {
|
|
186
250
|
const hasGit = existsSync(join(process.cwd(), '.git'));
|
|
@@ -204,3 +268,71 @@ export async function deploy(flags) {
|
|
|
204
268
|
function sleep(ms) {
|
|
205
269
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
206
270
|
}
|
|
271
|
+
|
|
272
|
+
async function dryRun(config, apiKey, appName) {
|
|
273
|
+
const instanceSize = config.instanceType || '4gb';
|
|
274
|
+
const cost = INSTANCE_COSTS[instanceSize];
|
|
275
|
+
|
|
276
|
+
console.log(`\n Dry run for: ${appName}`);
|
|
277
|
+
console.log(` ─────────────────────────────────\n`);
|
|
278
|
+
|
|
279
|
+
// 1. Config check
|
|
280
|
+
console.log(` Config: nometria.json`);
|
|
281
|
+
console.log(` Framework: ${config.framework || 'unknown'}`);
|
|
282
|
+
console.log(` Platform: ${config.platform || 'aws'} (${config.region || 'us-east-1'})`);
|
|
283
|
+
console.log(` Instance: ${instanceSize}${cost ? ` (${cost.type}) — ${cost.monthly}/mo` : ''}`);
|
|
284
|
+
console.log(` App ID: ${config.app_id || '(new — will be created)'}`);
|
|
285
|
+
console.log();
|
|
286
|
+
|
|
287
|
+
// 2. Auth check
|
|
288
|
+
console.log(` Checking auth...`);
|
|
289
|
+
try {
|
|
290
|
+
const authResult = await apiRequest('/cli/auth', { body: { api_key: apiKey } });
|
|
291
|
+
console.log(` Auth: OK (${authResult.email || 'authenticated'})`);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
console.log(` Auth: FAILED — ${err.message}`);
|
|
294
|
+
console.log(` Get a key: https://nometria.com/settings/api-keys\n`);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 3. Build check
|
|
299
|
+
if (config.build?.command) {
|
|
300
|
+
console.log(` Testing build: ${config.build.command}`);
|
|
301
|
+
try {
|
|
302
|
+
execSync(config.build.command, {
|
|
303
|
+
cwd: process.cwd(),
|
|
304
|
+
stdio: 'pipe',
|
|
305
|
+
env: { ...process.env, NODE_ENV: 'production' },
|
|
306
|
+
});
|
|
307
|
+
console.log(` Build: PASSED`);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.log(` Build: FAILED`);
|
|
310
|
+
console.error(`\n${err.stderr?.toString().slice(0, 500) || err.message}\n`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
console.log(` Build: (no build command)`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 4. Archive size estimate
|
|
318
|
+
try {
|
|
319
|
+
const tarball = createTarball(process.cwd(), config.ignore);
|
|
320
|
+
console.log(` Archive: ${tarball.sizeFormatted}`);
|
|
321
|
+
} catch {
|
|
322
|
+
console.log(` Archive: (could not estimate)`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 5. Status check
|
|
326
|
+
if (config.app_id) {
|
|
327
|
+
try {
|
|
328
|
+
const status = await apiRequest('/checkAwsStatus', { apiKey, body: { app_id: config.app_id } });
|
|
329
|
+
const state = status.instanceState || status.data?.instanceState || status.status || 'unknown';
|
|
330
|
+
console.log(` Instance: ${state}`);
|
|
331
|
+
} catch {
|
|
332
|
+
console.log(` Instance: (could not check)`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
console.log(`\n Dry run complete. Ready to deploy.`);
|
|
337
|
+
console.log(` Run: nom deploy\n`);
|
|
338
|
+
}
|
package/src/commands/env.js
CHANGED
|
@@ -5,11 +5,20 @@
|
|
|
5
5
|
* set KEY=VALUE [KEY=VALUE ...] Set environment variables
|
|
6
6
|
* list List variable names
|
|
7
7
|
* delete KEY [KEY ...] Delete variables
|
|
8
|
+
*
|
|
9
|
+
* Scope flags:
|
|
10
|
+
* --preview Target preview/staging environment
|
|
11
|
+
* --production Target production environment (default)
|
|
8
12
|
*/
|
|
9
13
|
import { readConfig } from '../lib/config.js';
|
|
10
14
|
import { requireApiKey } from '../lib/auth.js';
|
|
11
15
|
import { apiRequest } from '../lib/api.js';
|
|
12
16
|
|
|
17
|
+
function getScope(flags) {
|
|
18
|
+
if (flags.preview) return 'preview';
|
|
19
|
+
return 'production'; // default
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
export async function env(flags, positionals) {
|
|
14
23
|
const sub = positionals[0];
|
|
15
24
|
|
|
@@ -20,25 +29,47 @@ export async function env(flags, positionals) {
|
|
|
20
29
|
return envList(flags);
|
|
21
30
|
case 'delete':
|
|
22
31
|
return envDelete(flags, positionals.slice(1));
|
|
32
|
+
case 'compare':
|
|
33
|
+
return envCompare(flags);
|
|
34
|
+
case 'validate':
|
|
35
|
+
return envValidate(flags);
|
|
23
36
|
default:
|
|
24
37
|
console.log(`
|
|
25
|
-
Usage: nom env <command>
|
|
38
|
+
Usage: nom env <command> [options]
|
|
26
39
|
|
|
27
40
|
Commands:
|
|
28
41
|
set KEY=VALUE [...] Set environment variables
|
|
29
42
|
list List variable names
|
|
30
43
|
delete KEY [...] Delete variables
|
|
44
|
+
compare Compare preview vs production env vars
|
|
45
|
+
validate Check if required env vars are set
|
|
46
|
+
|
|
47
|
+
Options:
|
|
48
|
+
--preview Target preview/staging environment
|
|
49
|
+
--production Target production environment (default)
|
|
31
50
|
`);
|
|
32
51
|
}
|
|
33
52
|
}
|
|
34
53
|
|
|
54
|
+
const SECRET_PATTERNS = [
|
|
55
|
+
/^sk[-_]/i, /^pk[-_]/i, /secret/i, /password/i, /token/i,
|
|
56
|
+
/api[-_]?key/i, /private[-_]?key/i, /^ghp_/, /^gho_/, /^nometria_sk_/,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function looksLikeSecret(key, value) {
|
|
60
|
+
if (SECRET_PATTERNS.some(p => p.test(key))) return true;
|
|
61
|
+
if (value && value.length > 20 && /^[A-Za-z0-9+/=_-]+$/.test(value)) return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
async function envSet(flags, pairs) {
|
|
36
66
|
const apiKey = requireApiKey();
|
|
37
67
|
const config = readConfig();
|
|
38
68
|
const appId = config.app_id || config.name;
|
|
69
|
+
const scope = getScope(flags);
|
|
39
70
|
|
|
40
71
|
if (!pairs.length) {
|
|
41
|
-
console.error('\n Usage: nom env set KEY=VALUE [KEY=VALUE ...]\n');
|
|
72
|
+
console.error('\n Usage: nom env set KEY=VALUE [KEY=VALUE ...] [--preview|--production]\n');
|
|
42
73
|
process.exit(1);
|
|
43
74
|
}
|
|
44
75
|
|
|
@@ -51,12 +82,17 @@ async function envSet(flags, pairs) {
|
|
|
51
82
|
}
|
|
52
83
|
const key = pair.slice(0, eqIndex);
|
|
53
84
|
const value = pair.slice(eqIndex + 1);
|
|
85
|
+
|
|
86
|
+
// Warn about secrets
|
|
87
|
+
if (looksLikeSecret(key, value)) {
|
|
88
|
+
console.log(` Warning: "${key}" looks like a secret. Make sure it's not in nometria.json or git.`);
|
|
89
|
+
}
|
|
54
90
|
vars[key] = value;
|
|
55
91
|
}
|
|
56
92
|
|
|
57
93
|
const result = await apiRequest('/cli/env', {
|
|
58
94
|
apiKey,
|
|
59
|
-
body: { api_key: apiKey, app_id: appId, action: 'set', vars },
|
|
95
|
+
body: { api_key: apiKey, app_id: appId, action: 'set', vars, scope },
|
|
60
96
|
});
|
|
61
97
|
|
|
62
98
|
if (flags.json) {
|
|
@@ -65,17 +101,18 @@ async function envSet(flags, pairs) {
|
|
|
65
101
|
}
|
|
66
102
|
|
|
67
103
|
const keys = Object.keys(vars);
|
|
68
|
-
console.log(`\n Set ${keys.length} variable${keys.length === 1 ? '' : 's'}: ${keys.join(', ')}\n`);
|
|
104
|
+
console.log(`\n Set ${keys.length} variable${keys.length === 1 ? '' : 's'} (${scope}): ${keys.join(', ')}\n`);
|
|
69
105
|
}
|
|
70
106
|
|
|
71
107
|
async function envList(flags) {
|
|
72
108
|
const apiKey = requireApiKey();
|
|
73
109
|
const config = readConfig();
|
|
74
110
|
const appId = config.app_id || config.name;
|
|
111
|
+
const scope = getScope(flags);
|
|
75
112
|
|
|
76
113
|
const result = await apiRequest('/cli/env', {
|
|
77
114
|
apiKey,
|
|
78
|
-
body: { api_key: apiKey, app_id: appId, action: 'list' },
|
|
115
|
+
body: { api_key: apiKey, app_id: appId, action: 'list', scope },
|
|
79
116
|
});
|
|
80
117
|
|
|
81
118
|
if (flags.json) {
|
|
@@ -85,11 +122,11 @@ async function envList(flags) {
|
|
|
85
122
|
|
|
86
123
|
const vars = result.keys || result.vars || [];
|
|
87
124
|
if (!vars.length) {
|
|
88
|
-
console.log(
|
|
125
|
+
console.log(`\n No environment variables set (${scope}).\n`);
|
|
89
126
|
return;
|
|
90
127
|
}
|
|
91
128
|
|
|
92
|
-
console.log(
|
|
129
|
+
console.log(`\n Environment variables (${scope}):\n`);
|
|
93
130
|
for (const name of vars) {
|
|
94
131
|
console.log(` ${name}`);
|
|
95
132
|
}
|
|
@@ -100,15 +137,16 @@ async function envDelete(flags, keys) {
|
|
|
100
137
|
const apiKey = requireApiKey();
|
|
101
138
|
const config = readConfig();
|
|
102
139
|
const appId = config.app_id || config.name;
|
|
140
|
+
const scope = getScope(flags);
|
|
103
141
|
|
|
104
142
|
if (!keys.length) {
|
|
105
|
-
console.error('\n Usage: nom env delete KEY [KEY ...]\n');
|
|
143
|
+
console.error('\n Usage: nom env delete KEY [KEY ...] [--preview|--production]\n');
|
|
106
144
|
process.exit(1);
|
|
107
145
|
}
|
|
108
146
|
|
|
109
147
|
const result = await apiRequest('/cli/env', {
|
|
110
148
|
apiKey,
|
|
111
|
-
body: { api_key: apiKey, app_id: appId, action: 'delete', vars: keys },
|
|
149
|
+
body: { api_key: apiKey, app_id: appId, action: 'delete', vars: keys, scope },
|
|
112
150
|
});
|
|
113
151
|
|
|
114
152
|
if (flags.json) {
|
|
@@ -116,5 +154,116 @@ async function envDelete(flags, keys) {
|
|
|
116
154
|
return;
|
|
117
155
|
}
|
|
118
156
|
|
|
119
|
-
console.log(`\n Deleted ${keys.length} variable${keys.length === 1 ? '' : 's'}: ${keys.join(', ')}\n`);
|
|
157
|
+
console.log(`\n Deleted ${keys.length} variable${keys.length === 1 ? '' : 's'} (${scope}): ${keys.join(', ')}\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function envCompare(flags) {
|
|
161
|
+
const apiKey = requireApiKey();
|
|
162
|
+
const config = readConfig();
|
|
163
|
+
const appId = config.app_id || config.name;
|
|
164
|
+
|
|
165
|
+
const [prodResult, prevResult] = await Promise.all([
|
|
166
|
+
apiRequest('/cli/env', { apiKey, body: { api_key: apiKey, app_id: appId, action: 'list', scope: 'production' } }),
|
|
167
|
+
apiRequest('/cli/env', { apiKey, body: { api_key: apiKey, app_id: appId, action: 'list', scope: 'preview' } }),
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
if (flags.json) {
|
|
171
|
+
console.log(JSON.stringify({ production: prodResult, preview: prevResult }, null, 2));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const prodVars = new Set(prodResult.keys || prodResult.vars || []);
|
|
176
|
+
const prevVars = new Set(prevResult.keys || prevResult.vars || []);
|
|
177
|
+
const allKeys = new Set([...prodVars, ...prevVars]);
|
|
178
|
+
|
|
179
|
+
if (!allKeys.size) {
|
|
180
|
+
console.log('\n No environment variables set in either environment.\n');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log('\n Environment variable comparison:\n');
|
|
185
|
+
console.log(' Key Production Preview');
|
|
186
|
+
console.log(' ───────────────────────── ────────── ───────');
|
|
187
|
+
for (const key of [...allKeys].sort()) {
|
|
188
|
+
const inProd = prodVars.has(key) ? 'set' : '—';
|
|
189
|
+
const inPrev = prevVars.has(key) ? 'set' : '—';
|
|
190
|
+
const marker = inProd !== inPrev ? ' *' : '';
|
|
191
|
+
console.log(` ${key.padEnd(27)} ${inProd.padEnd(12)}${inPrev}${marker}`);
|
|
192
|
+
}
|
|
193
|
+
console.log('\n * = different between environments\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function envValidate(flags) {
|
|
197
|
+
const apiKey = requireApiKey();
|
|
198
|
+
const config = readConfig();
|
|
199
|
+
const appId = config.app_id || config.name;
|
|
200
|
+
const scope = getScope(flags);
|
|
201
|
+
|
|
202
|
+
// Collect expected vars from local .env and nometria.json
|
|
203
|
+
const { existsSync, readFileSync } = await import('node:fs');
|
|
204
|
+
const { join } = await import('node:path');
|
|
205
|
+
|
|
206
|
+
const expectedKeys = new Set();
|
|
207
|
+
|
|
208
|
+
// From local .env
|
|
209
|
+
const envPath = join(process.cwd(), '.env');
|
|
210
|
+
if (existsSync(envPath)) {
|
|
211
|
+
const lines = readFileSync(envPath, 'utf8').split('\n');
|
|
212
|
+
for (const line of lines) {
|
|
213
|
+
const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
|
|
214
|
+
if (match) expectedKeys.add(match[1]);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// From nometria.json env section
|
|
219
|
+
if (config.env && typeof config.env === 'object') {
|
|
220
|
+
for (const key of Object.keys(config.env)) {
|
|
221
|
+
expectedKeys.add(key);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!expectedKeys.size) {
|
|
226
|
+
console.log('\n No expected environment variables found in .env or nometria.json.\n');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Get deployed vars
|
|
231
|
+
const result = await apiRequest('/cli/env', {
|
|
232
|
+
apiKey,
|
|
233
|
+
body: { api_key: apiKey, app_id: appId, action: 'list', scope },
|
|
234
|
+
});
|
|
235
|
+
const deployedKeys = new Set(result.keys || result.vars || []);
|
|
236
|
+
|
|
237
|
+
// Compare
|
|
238
|
+
const missing = [...expectedKeys].filter(k => !deployedKeys.has(k));
|
|
239
|
+
const extra = [...deployedKeys].filter(k => !expectedKeys.has(k));
|
|
240
|
+
|
|
241
|
+
console.log(`\n Environment validation (${scope}):\n`);
|
|
242
|
+
console.log(` Local vars: ${expectedKeys.size}`);
|
|
243
|
+
console.log(` Deployed vars: ${deployedKeys.size}`);
|
|
244
|
+
|
|
245
|
+
if (missing.length) {
|
|
246
|
+
console.log(`\n Missing on ${scope} (${missing.length}):`);
|
|
247
|
+
for (const key of missing) {
|
|
248
|
+
console.log(` - ${key}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (extra.length) {
|
|
253
|
+
console.log(`\n Extra on ${scope} (not in local .env):`);
|
|
254
|
+
for (const key of extra) {
|
|
255
|
+
console.log(` + ${key}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!missing.length && !extra.length) {
|
|
260
|
+
console.log('\n All environment variables match.');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
console.log();
|
|
264
|
+
|
|
265
|
+
if (missing.length) {
|
|
266
|
+
console.log(` Fix: nom env set ${missing.map(k => `${k}=<value>`).join(' ')}\n`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
120
269
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* nom init — Create nometria.json config interactively
|
|
3
3
|
*/
|
|
4
4
|
import { writeFileSync } from 'node:fs';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
5
6
|
import { join, basename } from 'node:path';
|
|
6
|
-
import { detectFramework, detectPackageManager, detectServices } from '../lib/detect.js';
|
|
7
|
+
import { detectFramework, detectPackageManager, detectServices, detectMonorepo } from '../lib/detect.js';
|
|
7
8
|
import { configExists, CONFIG_FILE, VALID_PLATFORMS } from '../lib/config.js';
|
|
8
9
|
import { ask, choose, confirm } from '../lib/prompt.js';
|
|
9
10
|
|
|
@@ -30,10 +31,42 @@ export async function init(flags) {
|
|
|
30
31
|
|
|
31
32
|
console.log('\n Setting up your project for deployment\n');
|
|
32
33
|
|
|
34
|
+
// Detect monorepo
|
|
35
|
+
const mono = detectMonorepo(dir);
|
|
36
|
+
if (mono.isMonorepo && mono.packages.length > 0 && !flags.yes) {
|
|
37
|
+
console.log(` Monorepo detected (${mono.tool}). Deployable packages:`);
|
|
38
|
+
for (let i = 0; i < mono.packages.length; i++) {
|
|
39
|
+
console.log(` ${i + 1}. ${mono.packages[i]}`);
|
|
40
|
+
}
|
|
41
|
+
console.log();
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
// Detect framework and services
|
|
34
|
-
|
|
45
|
+
let detected = detectFramework(dir);
|
|
35
46
|
const pkgManager = detectPackageManager(dir);
|
|
36
47
|
const { services, docker_compose } = detectServices(dir);
|
|
48
|
+
|
|
49
|
+
// If detection is uncertain, warn and let user override
|
|
50
|
+
if (detected.uncertain && !flags.yes) {
|
|
51
|
+
console.log(` Could not confidently detect framework (defaulting to "static").`);
|
|
52
|
+
const frameworks = ['static', 'vite', 'nextjs', 'remix', 'astro', 'sveltekit', 'nuxt', 'node'];
|
|
53
|
+
const choice = await choose('What framework is this project?', frameworks, 0);
|
|
54
|
+
if (choice !== 'static') {
|
|
55
|
+
detected = detectFramework(dir); // re-detect won't help, set manually
|
|
56
|
+
detected.framework = choice;
|
|
57
|
+
detected.uncertain = false;
|
|
58
|
+
if (['vite', 'astro', 'sveltekit'].includes(choice)) {
|
|
59
|
+
detected.build = { command: 'npm run build', output: choice === 'sveltekit' ? 'build' : 'dist' };
|
|
60
|
+
} else if (choice === 'nextjs') {
|
|
61
|
+
detected.build = { command: 'npm run build', output: '.next' };
|
|
62
|
+
} else if (choice === 'nuxt') {
|
|
63
|
+
detected.build = { command: 'npm run build', output: '.output' };
|
|
64
|
+
} else if (choice === 'remix') {
|
|
65
|
+
detected.build = { command: 'npm run build', output: 'build' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
37
70
|
console.log(` Detected: ${detected.framework} (${pkgManager})`);
|
|
38
71
|
if (services.length > 0) {
|
|
39
72
|
console.log(` Services: ${services.map(s => `${s.name} (${s.type})`).join(', ')}`);
|
|
@@ -90,6 +123,21 @@ export async function init(flags) {
|
|
|
90
123
|
|
|
91
124
|
console.log(`\n Created ${CONFIG_FILE}`);
|
|
92
125
|
|
|
126
|
+
// Validate build if a build command exists
|
|
127
|
+
if (buildCmd && !flags.yes) {
|
|
128
|
+
const shouldValidate = await confirm('Test the build command now?', true);
|
|
129
|
+
if (shouldValidate) {
|
|
130
|
+
console.log(`\n Running: ${buildCmd}`);
|
|
131
|
+
try {
|
|
132
|
+
execSync(buildCmd, { cwd: dir, stdio: 'inherit', env: { ...process.env, NODE_ENV: 'production' } });
|
|
133
|
+
console.log(`\n Build passed.\n`);
|
|
134
|
+
} catch {
|
|
135
|
+
console.log(`\n Build failed. Fix the errors above, then run: nom deploy`);
|
|
136
|
+
console.log(` Help: https://docs.nometria.com/deploy/overview\n`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
93
141
|
// Auto-generate AI tool configs
|
|
94
142
|
const { setup } = await import('./setup.js');
|
|
95
143
|
await setup({ yes: true });
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nom list — List all your deployed apps.
|
|
3
|
+
*/
|
|
4
|
+
import { requireApiKey } from '../lib/auth.js';
|
|
5
|
+
import { apiRequest } from '../lib/api.js';
|
|
6
|
+
import { getCached, setCache } from '../lib/cache.js';
|
|
7
|
+
|
|
8
|
+
export async function list(flags) {
|
|
9
|
+
const apiKey = requireApiKey();
|
|
10
|
+
|
|
11
|
+
// Check cache first (1 minute TTL), skip with --no-cache
|
|
12
|
+
const noCache = flags['no-cache'] || flags.noCache;
|
|
13
|
+
if (!noCache) {
|
|
14
|
+
const cached = getCached('list_apps');
|
|
15
|
+
if (cached) {
|
|
16
|
+
const migrations = cached.migrations || cached || [];
|
|
17
|
+
printApps(migrations, flags);
|
|
18
|
+
console.log(' (cached — use --no-cache to refresh)\n');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const result = await apiRequest('/listUserMigrations', { apiKey, body: {} });
|
|
24
|
+
const migrations = result.migrations || result || [];
|
|
25
|
+
setCache('list_apps', result);
|
|
26
|
+
|
|
27
|
+
printApps(migrations, flags);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printApps(migrations, flags) {
|
|
31
|
+
if (!migrations.length) {
|
|
32
|
+
console.log('\n No apps found. Deploy your first app: nom deploy\n');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(`\n Your apps (${migrations.length}):\n`);
|
|
37
|
+
|
|
38
|
+
if (flags.json) {
|
|
39
|
+
console.log(JSON.stringify(migrations, null, 2));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const m of migrations) {
|
|
44
|
+
const status = m.delivery_type === 'hosting' ? (m.payment_status === 'paid' ? 'hosting' : 'unpaid') : m.delivery_type || 'download';
|
|
45
|
+
const platform = m.platform || '—';
|
|
46
|
+
const url = m.hosted_url || m.deploy_url || '—';
|
|
47
|
+
console.log(` ${m.app_name || m.app_id}`);
|
|
48
|
+
console.log(` Platform: ${platform} Status: ${status} URL: ${url}`);
|
|
49
|
+
console.log();
|
|
50
|
+
}
|
|
51
|
+
}
|