@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
|
@@ -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
|
+
}
|
package/src/commands/setup.js
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/lib/cache.js
ADDED
|
@@ -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
|
-
|
|
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
|
+
}
|