@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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * nom rollback — Roll back to a previous deployment.
3
+ */
4
+ import { requireApiKey } from '../lib/auth.js';
5
+ import { apiRequest } from '../lib/api.js';
6
+ import { readConfig } from '../lib/config.js';
7
+ import { createSpinner } from '../lib/spinner.js';
8
+ import { confirm } from '../lib/prompt.js';
9
+
10
+ export async function rollback(flags, positionals) {
11
+ const apiKey = requireApiKey();
12
+ const config = readConfig();
13
+ const appId = config.app_id;
14
+
15
+ if (!appId) {
16
+ console.error('\n No app_id in nometria.json. Deploy first: nom deploy\n');
17
+ process.exit(1);
18
+ }
19
+
20
+ // Step 1: List recent deployments
21
+ const spinner = createSpinner('Fetching deployment history').start();
22
+ let deployments;
23
+ try {
24
+ const result = await apiRequest('/v1/deployments', {
25
+ apiKey,
26
+ body: { app_id: appId },
27
+ });
28
+ deployments = result.deployments || result.data?.deployments || [];
29
+ spinner.succeed(`Found ${deployments.length} deployment(s)`);
30
+ } catch (err) {
31
+ spinner.fail('Failed to fetch deployment history');
32
+ console.error(`\n ${err.message}`);
33
+ console.error(` This app may not support rollback yet.`);
34
+ console.error(` Help: https://docs.nometria.com/deploy/overview\n`);
35
+ process.exit(1);
36
+ }
37
+
38
+ if (deployments.length < 2) {
39
+ console.log('\n No previous deployments to roll back to.\n');
40
+ return;
41
+ }
42
+
43
+ // Step 2: Show recent deployments
44
+ console.log('\n Recent deployments:\n');
45
+ for (let i = 0; i < Math.min(deployments.length, 10); i++) {
46
+ const d = deployments[i];
47
+ const marker = i === 0 ? ' (current)' : '';
48
+ const status = d.status || 'unknown';
49
+ const date = d.created_at ? new Date(d.created_at).toLocaleString() : '—';
50
+ console.log(` ${i + 1}. ${d.id} ${status} ${date}${marker}`);
51
+ }
52
+ console.log();
53
+
54
+ // Step 3: Determine target
55
+ let targetId = positionals?.[0];
56
+ if (!targetId) {
57
+ // Default to the second deployment (previous)
58
+ if (deployments.length >= 2) {
59
+ targetId = deployments[1].id;
60
+ }
61
+ }
62
+
63
+ if (!targetId) {
64
+ console.error('\n No deployment to roll back to.\n');
65
+ process.exit(1);
66
+ }
67
+
68
+ // Step 4: Confirm
69
+ if (!flags.yes) {
70
+ const ok = await confirm(`Roll back to ${targetId}?`, false);
71
+ if (!ok) {
72
+ console.log(' Cancelled.\n');
73
+ return;
74
+ }
75
+ }
76
+
77
+ // Step 5: Execute rollback
78
+ const rollbackSpinner = createSpinner('Rolling back').start();
79
+ try {
80
+ const result = await apiRequest(`/v1/deployments/${targetId}/rollback`, {
81
+ apiKey,
82
+ body: { app_id: appId },
83
+ });
84
+ rollbackSpinner.succeed('Rollback complete');
85
+ console.log(`\n Rolled back to: ${targetId}`);
86
+ if (result.url) console.log(` URL: ${result.url}`);
87
+ console.log(` Dashboard: https://nometria.com/AppDetails?app_id=${appId}\n`);
88
+ } catch (err) {
89
+ rollbackSpinner.fail('Rollback failed');
90
+ console.error(`\n ${err.message}`);
91
+ console.error(` Dashboard: https://nometria.com/AppDetails?app_id=${appId}`);
92
+ console.error(` Help: https://docs.nometria.com/deploy/overview\n`);
93
+ process.exit(1);
94
+ }
95
+ }
@@ -90,6 +90,16 @@ export async function setup(flags) {
90
90
  writeContinueConfig(continueConfigPath);
91
91
  files.push('.continue/config.json');
92
92
 
93
+ // 10. Claude Code automation hooks
94
+ const hooksDir = join(dir, '.claude', 'hooks');
95
+ mkdirSync(hooksDir, { recursive: true });
96
+ writeFileSync(join(hooksDir, 'auto-deploy-on-commit.sh'), autoDeployHook(), { mode: 0o755 });
97
+ files.push('.claude/hooks/auto-deploy-on-commit.sh');
98
+ writeFileSync(join(hooksDir, 'security-gate.sh'), securityGateHook(), { mode: 0o755 });
99
+ files.push('.claude/hooks/security-gate.sh');
100
+ writeFileSync(join(hooksDir, 'cost-guardian.sh'), costGuardianHook(), { mode: 0o755 });
101
+ files.push('.claude/hooks/cost-guardian.sh');
102
+
93
103
  // Print results
94
104
  for (const f of files) {
95
105
  console.log(` ${f}`);
@@ -105,7 +115,15 @@ export async function setup(flags) {
105
115
  /deploy Deploy to production
106
116
  /preview Staging preview (free, 2hr)
107
117
  /status Check deployment status
118
+ /logs View deployment logs
119
+ /env Manage environment variables
120
+ /domain Add custom domains
108
121
  /nometria-login Set up authentication
122
+
123
+ Automation hooks installed:
124
+ auto-deploy-on-commit.sh Auto-resync on git commit
125
+ security-gate.sh Block deploys with low security score
126
+ cost-guardian.sh Warn about idle running instances
109
127
  `);
110
128
  }
111
129
 
@@ -298,9 +316,14 @@ on:
298
316
  branches: [main]
299
317
  workflow_dispatch:
300
318
 
319
+ concurrency:
320
+ group: nometria-deploy
321
+ cancel-in-progress: true
322
+
301
323
  jobs:
302
324
  deploy:
303
325
  runs-on: ubuntu-latest
326
+ timeout-minutes: 15
304
327
  steps:
305
328
  - uses: actions/checkout@v4
306
329
 
@@ -311,10 +334,30 @@ jobs:
311
334
  - name: Install dependencies
312
335
  run: npm ci
313
336
 
314
- - name: Deploy
337
+ - name: Run security scan
338
+ run: npx @nometria-ai/nom scan || echo "Scan completed with warnings"
339
+ env:
340
+ NOMETRIA_API_KEY: \${{ secrets.NOMETRIA_API_KEY }}
341
+
342
+ - name: Deploy to production
343
+ id: deploy
315
344
  run: npx @nometria-ai/nom deploy --yes
316
345
  env:
317
346
  NOMETRIA_API_KEY: \${{ secrets.NOMETRIA_API_KEY }}
347
+
348
+ - name: Verify deployment
349
+ if: success()
350
+ run: |
351
+ echo "Deployment successful"
352
+ npx @nometria-ai/nom status --json || true
353
+ env:
354
+ NOMETRIA_API_KEY: \${{ secrets.NOMETRIA_API_KEY }}
355
+
356
+ - name: Notify on failure
357
+ if: failure()
358
+ run: |
359
+ echo "::error::Deployment failed. Check logs: npx @nometria-ai/nom logs"
360
+ echo "Dashboard: https://nometria.com/dashboard"
318
361
  `;
319
362
  }
320
363
 
@@ -852,3 +895,104 @@ function writeContinueConfig(configPath) {
852
895
 
853
896
  writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
854
897
  }
898
+
899
+ // ─── Automation Hook Templates ─────────────────────────────────────────────
900
+
901
+ function autoDeployHook() {
902
+ return `#!/usr/bin/env bash
903
+ # Nometria: Auto-deploy on git commit
904
+ # Triggers resync when a git commit is made in a Nometria project.
905
+ set -euo pipefail
906
+
907
+ TOOL_INPUT="\${CLAUDE_TOOL_INPUT:-}"
908
+ if ! echo "$TOOL_INPUT" | grep -q "git commit"; then exit 0; fi
909
+ if [ ! -f "nometria.json" ]; then exit 0; fi
910
+
911
+ APP_ID=$(grep -o '"app_id"[[:space:]]*:[[:space:]]*"[^"]*"' nometria.json 2>/dev/null | head -1 | cut -d'"' -f4)
912
+ [ -z "$APP_ID" ] && exit 0
913
+
914
+ TOKEN="\${NOMETRIA_API_KEY:-\${NOMETRIA_TOKEN:-}}"
915
+ [ -z "$TOKEN" ] && [ -f .env ] && TOKEN=$(grep -s 'NOMETRIA_API_KEY\\|NOMETRIA_TOKEN' .env 2>/dev/null | head -1 | cut -d= -f2- | tr -d ' "'"'"'')
916
+ [ -z "$TOKEN" ] && [ -f "$HOME/.nometria/credentials.json" ] && TOKEN=$(grep -o '"api_key"[[:space:]]*:[[:space:]]*"[^"]*"' "$HOME/.nometria/credentials.json" 2>/dev/null | head -1 | cut -d'"' -f4)
917
+ [ -z "$TOKEN" ] && exit 0
918
+
919
+ curl -sf -X POST https://app.nometria.com/resyncHosting \\
920
+ -H "Content-Type: application/json" \\
921
+ -H "Authorization: Bearer $TOKEN" \\
922
+ -d "{\\"app_id\\": \\"$APP_ID\\"}" > /dev/null 2>&1 &
923
+
924
+ echo "Nometria: resyncing $APP_ID in background..."
925
+ `;
926
+ }
927
+
928
+ function securityGateHook() {
929
+ return `#!/usr/bin/env bash
930
+ # Nometria: Security gate before deploy
931
+ # Blocks production deploys if security score < 70.
932
+ set -euo pipefail
933
+
934
+ TOOL_NAME="\${CLAUDE_TOOL_NAME:-}"
935
+ TOOL_INPUT="\${CLAUDE_TOOL_INPUT:-}"
936
+
937
+ IS_DEPLOY=false
938
+ [ "$TOOL_NAME" = "Skill" ] && echo "$TOOL_INPUT" | grep -q '"deploy"' && IS_DEPLOY=true
939
+ [ "$TOOL_NAME" = "Bash" ] && echo "$TOOL_INPUT" | grep -qE "deployToAws|resyncHosting|nom deploy" && IS_DEPLOY=true
940
+ [ "$IS_DEPLOY" != "true" ] && exit 0
941
+ [ ! -f "nometria.json" ] && exit 0
942
+
943
+ APP_ID=$(grep -o '"app_id"[[:space:]]*:[[:space:]]*"[^"]*"' nometria.json 2>/dev/null | head -1 | cut -d'"' -f4)
944
+ MIGRATION_ID=$(grep -o '"migration_id"[[:space:]]*:[[:space:]]*"[^"]*"' nometria.json 2>/dev/null | head -1 | cut -d'"' -f4)
945
+ [ -z "$APP_ID" ] || [ -z "$MIGRATION_ID" ] && exit 0
946
+
947
+ TOKEN="\${NOMETRIA_API_KEY:-\${NOMETRIA_TOKEN:-}}"
948
+ [ -z "$TOKEN" ] && [ -f .env ] && TOKEN=$(grep -s 'NOMETRIA_API_KEY\\|NOMETRIA_TOKEN' .env 2>/dev/null | head -1 | cut -d= -f2- | tr -d ' "'"'"'')
949
+ [ -z "$TOKEN" ] && [ -f "$HOME/.nometria/credentials.json" ] && TOKEN=$(grep -o '"api_key"[[:space:]]*:[[:space:]]*"[^"]*"' "$HOME/.nometria/credentials.json" 2>/dev/null | head -1 | cut -d'"' -f4)
950
+ [ -z "$TOKEN" ] && exit 0
951
+
952
+ echo "Nometria: running security scan before deploy..."
953
+ SCAN_RESULT=$(curl -sf -X POST https://app.nometria.com/runAiScan \\
954
+ -H "Content-Type: application/json" \\
955
+ -H "Authorization: Bearer $TOKEN" \\
956
+ -d "{\\"app_id\\": \\"$APP_ID\\", \\"migration_id\\": \\"$MIGRATION_ID\\"}" 2>/dev/null || echo '{}')
957
+
958
+ SCORE=$(echo "$SCAN_RESULT" | grep -o '"securityScore"[[:space:]]*:[[:space:]]*[0-9]*' | grep -o '[0-9]*$')
959
+
960
+ if [ -n "$SCORE" ] && [ "$SCORE" -lt 70 ]; then
961
+ echo "BLOCKED: Security score $SCORE/100 (minimum: 70). Run 'nom scan' for details."
962
+ exit 2
963
+ fi
964
+ [ -n "$SCORE" ] && echo "Nometria: security score $SCORE/100 — passed."
965
+ `;
966
+ }
967
+
968
+ function costGuardianHook() {
969
+ return `#!/usr/bin/env bash
970
+ # Nometria: Cost guardian — detect idle instances on session start
971
+ set -euo pipefail
972
+
973
+ TOKEN="\${NOMETRIA_API_KEY:-\${NOMETRIA_TOKEN:-}}"
974
+ [ -z "$TOKEN" ] && [ -f .env ] && TOKEN=$(grep -s 'NOMETRIA_API_KEY\\|NOMETRIA_TOKEN' .env 2>/dev/null | head -1 | cut -d= -f2- | tr -d ' "'"'"'')
975
+ [ -z "$TOKEN" ] && [ -f "$HOME/.nometria/credentials.json" ] && TOKEN=$(grep -o '"api_key"[[:space:]]*:[[:space:]]*"[^"]*"' "$HOME/.nometria/credentials.json" 2>/dev/null | head -1 | cut -d'"' -f4)
976
+ [ -z "$TOKEN" ] && exit 0
977
+
978
+ MIGRATIONS=$(curl -sf -X POST https://app.nometria.com/listUserMigrations \\
979
+ -H "Content-Type: application/json" \\
980
+ -H "Authorization: Bearer $TOKEN" \\
981
+ -d '{}' 2>/dev/null || echo '{}')
982
+
983
+ APP_IDS=$(echo "$MIGRATIONS" | grep -o '"app_id"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
984
+ [ -z "$APP_IDS" ] && exit 0
985
+
986
+ RUNNING=0
987
+ for APP_ID in $APP_IDS; do
988
+ STATUS=$(curl -sf -X POST https://app.nometria.com/checkAwsStatus \\
989
+ -H "Content-Type: application/json" \\
990
+ -H "Authorization: Bearer $TOKEN" \\
991
+ -d "{\\"app_id\\": \\"$APP_ID\\"}" 2>/dev/null || echo '{}')
992
+ STATE=$(echo "$STATUS" | grep -o '"instanceState"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
993
+ [ "$STATE" = "running" ] && RUNNING=$((RUNNING + 1)) && echo " Running: $APP_ID"
994
+ done
995
+
996
+ [ "$RUNNING" -gt 0 ] && echo "Nometria: $RUNNING running instance(s). Stop idle ones with: nom stop <app_id>"
997
+ `;
998
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * nom ssh — Get SSH/exec access to a deployed instance.
3
+ * Uses AWS SSM or provides SSH instructions.
4
+ */
5
+ import { requireApiKey } from '../lib/auth.js';
6
+ import { apiRequest } from '../lib/api.js';
7
+ import { readConfig } from '../lib/config.js';
8
+
9
+ export async function ssh(flags, positionals) {
10
+ const apiKey = requireApiKey();
11
+ const config = readConfig();
12
+ const appId = config.app_id;
13
+
14
+ if (!appId) {
15
+ console.error('\n No app_id in nometria.json. Deploy first: nom deploy');
16
+ console.error(' Docs: https://docs.nometria.com/cli/commands\n');
17
+ process.exit(1);
18
+ }
19
+
20
+ // Get instance details
21
+ const result = await apiRequest('/checkAwsStatus', {
22
+ apiKey,
23
+ body: { app_id: appId },
24
+ });
25
+
26
+ const data = result.data || result;
27
+ const state = data.instanceState;
28
+ const ip = data.ipAddress;
29
+ const instanceId = data.instanceId;
30
+
31
+ if (state !== 'running') {
32
+ console.error(`\n Instance is not running (state: ${state || 'unknown'}).`);
33
+ console.error(' Start it first: nom start\n');
34
+ process.exit(1);
35
+ }
36
+
37
+ if (!ip && !instanceId) {
38
+ console.error('\n Could not determine instance IP or ID.');
39
+ console.error(` Dashboard: https://nometria.com/AppDetails?app_id=${appId}\n`);
40
+ process.exit(1);
41
+ }
42
+
43
+ // Check if user wants to run a command
44
+ const command = positionals.join(' ');
45
+
46
+ if (command) {
47
+ // nom ssh <command> — execute remote command
48
+ console.log(`\n Running on ${appId} (${ip || instanceId}):\n`);
49
+ try {
50
+ const execResult = await apiRequest('/cli/exec', {
51
+ apiKey,
52
+ body: { app_id: appId, command },
53
+ });
54
+ console.log(execResult.output || execResult.stdout || '(no output)');
55
+ if (execResult.stderr) console.error(execResult.stderr);
56
+ console.log();
57
+ } catch (err) {
58
+ // Fallback: show SSH instructions
59
+ console.error(` Remote exec not available: ${err.message}\n`);
60
+ showSshInstructions(ip, instanceId);
61
+ }
62
+ } else {
63
+ // nom ssh — show connection instructions
64
+ showSshInstructions(ip, instanceId);
65
+ }
66
+ }
67
+
68
+ function showSshInstructions(ip, instanceId) {
69
+ console.log('\n Connect to your instance:\n');
70
+
71
+ if (ip) {
72
+ console.log(` SSH: ssh ubuntu@${ip}`);
73
+ console.log(` (requires your SSH key to be configured)\n`);
74
+ }
75
+
76
+ if (instanceId) {
77
+ console.log(` SSM: aws ssm start-session --target ${instanceId}`);
78
+ console.log(` (requires AWS CLI + Session Manager plugin)\n`);
79
+ }
80
+
81
+ console.log(' Common debug commands:');
82
+ console.log(' pm2 status Check running processes');
83
+ console.log(' pm2 logs View app logs');
84
+ console.log(' sudo nginx -t Test nginx config');
85
+ console.log(' cat /home/ubuntu/deploy.log View deploy log');
86
+ console.log(' df -h Check disk space');
87
+ console.log();
88
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Simple file-based cache for CLI responses.
3
+ * Stores cached data in ~/.nometria/cache/ with TTL-based expiration.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+
9
+ const CACHE_DIR = join(homedir(), '.nometria', 'cache');
10
+ const DEFAULT_TTL = 60_000; // 1 minute
11
+
12
+ function ensureCacheDir() {
13
+ if (!existsSync(CACHE_DIR)) {
14
+ mkdirSync(CACHE_DIR, { recursive: true });
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Get a cached value. Returns null if expired or not found.
20
+ */
21
+ export function getCached(key, ttl = DEFAULT_TTL) {
22
+ try {
23
+ const path = join(CACHE_DIR, `${key}.json`);
24
+ if (!existsSync(path)) return null;
25
+
26
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
27
+ if (Date.now() - raw.timestamp > ttl) return null;
28
+ return raw.data;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Store a value in cache.
36
+ */
37
+ export function setCache(key, data) {
38
+ try {
39
+ ensureCacheDir();
40
+ const path = join(CACHE_DIR, `${key}.json`);
41
+ writeFileSync(path, JSON.stringify({ timestamp: Date.now(), data }));
42
+ } catch {
43
+ // Non-fatal — cache write failure shouldn't break CLI
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Cache-aware API request wrapper.
49
+ * Uses cache for reads, bypasses for writes.
50
+ */
51
+ export function withCache(key, ttl = DEFAULT_TTL) {
52
+ return {
53
+ get() { return getCached(key, ttl); },
54
+ set(data) { setCache(key, data); },
55
+ };
56
+ }
package/src/lib/detect.js CHANGED
@@ -74,6 +74,48 @@ export function detectFramework(dir = process.cwd()) {
74
74
  if (deps['vite']) return { framework: 'vite', build: { command: hasBuildScript ? 'npm run build' : null, output: 'dist' } };
75
75
  }
76
76
 
77
+ // Check for Hono
78
+ if (pkg) {
79
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
80
+ if (deps['hono']) return { framework: 'hono', build: { command: null, output: '.' } };
81
+ }
82
+
83
+ // Check for Solid
84
+ if (existsSync(join(dir, 'solid.config.js')) || existsSync(join(dir, 'solid.config.ts'))) {
85
+ return { framework: 'solid', build: { command: 'npm run build', output: 'dist' } };
86
+ }
87
+ if (pkg) {
88
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
89
+ if (deps['solid-js'] && deps['solid-start']) return { framework: 'solid', build: { command: 'npm run build', output: 'dist' } };
90
+ }
91
+
92
+ // Check for Astro
93
+ if (existsSync(join(dir, 'astro.config.mjs')) || existsSync(join(dir, 'astro.config.ts'))) {
94
+ return { framework: 'astro', build: { command: 'npm run build', output: 'dist' } };
95
+ }
96
+ if (pkg) {
97
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
98
+ if (deps['astro']) return { framework: 'astro', build: { command: 'npm run build', output: 'dist' } };
99
+ }
100
+
101
+ // Check for SvelteKit
102
+ if (existsSync(join(dir, 'svelte.config.js')) || existsSync(join(dir, 'svelte.config.ts'))) {
103
+ return { framework: 'sveltekit', build: { command: 'npm run build', output: 'build' } };
104
+ }
105
+ if (pkg) {
106
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
107
+ if (deps['@sveltejs/kit']) return { framework: 'sveltekit', build: { command: 'npm run build', output: 'build' } };
108
+ }
109
+
110
+ // Check for Nuxt
111
+ if (existsSync(join(dir, 'nuxt.config.ts')) || existsSync(join(dir, 'nuxt.config.js'))) {
112
+ return { framework: 'nuxt', build: { command: 'npm run build', output: '.output' } };
113
+ }
114
+ if (pkg) {
115
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
116
+ if (deps['nuxt']) return { framework: 'nuxt', build: { command: 'npm run build', output: '.output' } };
117
+ }
118
+
77
119
  // Check if it looks like a Node.js project
78
120
  if (pkg && (pkg.main || pkg.scripts?.start)) {
79
121
  return { framework: 'node', build: { command: null, output: '.' } };
@@ -85,7 +127,8 @@ export function detectFramework(dir = process.cwd()) {
85
127
  return { framework: 'python', build: { command: null, output: '.' } };
86
128
  }
87
129
 
88
- return { framework: 'static', build: { command: null, output: '.' } };
130
+ // Flag as uncertain let callers decide whether to prompt or default
131
+ return { framework: 'static', build: { command: null, output: '.' }, uncertain: true };
89
132
  }
90
133
 
91
134
  export function detectPackageManager(dir = process.cwd()) {
@@ -212,6 +255,69 @@ export function detectServices(dir = process.cwd()) {
212
255
  return result;
213
256
  }
214
257
 
258
+ /**
259
+ * Detect if the project is a monorepo.
260
+ * Returns { isMonorepo: boolean, tool: string|null, packages: string[] }
261
+ */
262
+ export function detectMonorepo(dir = process.cwd()) {
263
+ const result = { isMonorepo: false, tool: null, packages: [] };
264
+
265
+ // Check for monorepo config files
266
+ if (existsSync(join(dir, 'turbo.json'))) {
267
+ result.isMonorepo = true;
268
+ result.tool = 'turborepo';
269
+ } else if (existsSync(join(dir, 'nx.json'))) {
270
+ result.isMonorepo = true;
271
+ result.tool = 'nx';
272
+ } else if (existsSync(join(dir, 'pnpm-workspace.yaml'))) {
273
+ result.isMonorepo = true;
274
+ result.tool = 'pnpm-workspaces';
275
+ } else if (existsSync(join(dir, 'lerna.json'))) {
276
+ result.isMonorepo = true;
277
+ result.tool = 'lerna';
278
+ }
279
+
280
+ // Check package.json workspaces
281
+ if (!result.isMonorepo) {
282
+ const pkgPath = join(dir, 'package.json');
283
+ if (existsSync(pkgPath)) {
284
+ try {
285
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
286
+ if (pkg.workspaces) {
287
+ result.isMonorepo = true;
288
+ result.tool = 'npm-workspaces';
289
+ }
290
+ } catch { /* ignore */ }
291
+ }
292
+ }
293
+
294
+ if (!result.isMonorepo) return result;
295
+
296
+ // Find deployable packages (those with package.json and a build or start script)
297
+ const packagesDir = existsSync(join(dir, 'packages')) ? join(dir, 'packages') :
298
+ existsSync(join(dir, 'apps')) ? join(dir, 'apps') : null;
299
+
300
+ if (packagesDir) {
301
+ try {
302
+ const entries = readdirSync(packagesDir, { withFileTypes: true });
303
+ for (const entry of entries) {
304
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
305
+ const pkgJson = join(packagesDir, entry.name, 'package.json');
306
+ if (existsSync(pkgJson)) {
307
+ try {
308
+ const pkg = JSON.parse(readFileSync(pkgJson, 'utf8'));
309
+ if (pkg.scripts?.build || pkg.scripts?.start || pkg.scripts?.dev) {
310
+ result.packages.push(entry.name);
311
+ }
312
+ } catch { /* skip */ }
313
+ }
314
+ }
315
+ } catch { /* skip */ }
316
+ }
317
+
318
+ return result;
319
+ }
320
+
215
321
  export function getProjectName(dir = process.cwd()) {
216
322
  const pkgPath = join(dir, 'package.json');
217
323
  if (existsSync(pkgPath)) {
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Anonymous CLI telemetry — tracks deployment funnel to identify drop-off points.
3
+ *
4
+ * Opt-out: set NOM_TELEMETRY=0 or run `nom config telemetry false`
5
+ *
6
+ * What we track:
7
+ * - Command name (deploy, init, preview, etc.)
8
+ * - Framework detected
9
+ * - Success/failure
10
+ * - Duration
11
+ *
12
+ * What we DON'T track:
13
+ * - App names, URLs, or content
14
+ * - API keys or tokens
15
+ * - File paths or code
16
+ * - IP addresses (server-side)
17
+ */
18
+ import { homedir } from 'node:os';
19
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
20
+ import { join, dirname } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const TELEMETRY_ENDPOINT = 'https://app.nometria.com/cli/telemetry';
24
+ const CONFIG_PATH = join(homedir(), '.nometria', 'config.json');
25
+
26
+ function isEnabled() {
27
+ // Env var override
28
+ if (process.env.NOM_TELEMETRY === '0' || process.env.NOM_TELEMETRY === 'false') return false;
29
+ if (process.env.DO_NOT_TRACK === '1') return false; // Respect consented.org standard
30
+
31
+ // Config file override
32
+ try {
33
+ if (existsSync(CONFIG_PATH)) {
34
+ const config = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
35
+ if (config.telemetry === false) return false;
36
+ }
37
+ } catch { /* ignore */ }
38
+
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * Record a CLI event. Non-blocking — fire and forget.
44
+ */
45
+ export function trackEvent(event, properties = {}) {
46
+ if (!isEnabled()) return;
47
+
48
+ const payload = {
49
+ event,
50
+ properties: {
51
+ ...properties,
52
+ cli_version: getCliVersion(),
53
+ node_version: process.version,
54
+ platform: process.platform,
55
+ arch: process.arch,
56
+ },
57
+ timestamp: new Date().toISOString(),
58
+ };
59
+
60
+ // Fire and forget — never block the CLI
61
+ try {
62
+ fetch(TELEMETRY_ENDPOINT, {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json' },
65
+ body: JSON.stringify(payload),
66
+ signal: AbortSignal.timeout(3000),
67
+ }).catch(() => { /* silently ignore */ });
68
+ } catch { /* silently ignore */ }
69
+ }
70
+
71
+ /**
72
+ * Track command start. Returns a finish function to record duration.
73
+ */
74
+ export function trackCommand(command, metadata = {}) {
75
+ const start = Date.now();
76
+ trackEvent('command_start', { command, ...metadata });
77
+
78
+ return {
79
+ success(extra = {}) {
80
+ trackEvent('command_success', {
81
+ command,
82
+ duration_ms: Date.now() - start,
83
+ ...metadata,
84
+ ...extra,
85
+ });
86
+ },
87
+ fail(error, extra = {}) {
88
+ trackEvent('command_fail', {
89
+ command,
90
+ duration_ms: Date.now() - start,
91
+ error: typeof error === 'string' ? error : error?.message || 'unknown',
92
+ ...metadata,
93
+ ...extra,
94
+ });
95
+ },
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Set telemetry preference.
101
+ */
102
+ export function setTelemetry(enabled) {
103
+ const dir = join(homedir(), '.nometria');
104
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
105
+
106
+ let config = {};
107
+ try {
108
+ if (existsSync(CONFIG_PATH)) config = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
109
+ } catch { /* start fresh */ }
110
+
111
+ config.telemetry = enabled;
112
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
113
+ }
114
+
115
+ function getCliVersion() {
116
+ try {
117
+ const dir = dirname(fileURLToPath(import.meta.url));
118
+ const pkg = JSON.parse(readFileSync(join(dir, '..', '..', 'package.json'), 'utf8'));
119
+ return pkg.version;
120
+ } catch {
121
+ return 'unknown';
122
+ }
123
+ }