@sinch/cli 0.3.3 → 0.3.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sinch/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Official Sinch CLI - Manage all Sinch products from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -21,7 +21,8 @@
21
21
  "test:smoke": "node scripts/smoke-test.js",
22
22
  "setup": "npm run build && npm link && npm run build:binary && node scripts/setup-dev.js",
23
23
  "format": "prettier --write .",
24
- "format:check": "prettier --check ."
24
+ "format:check": "prettier --check .",
25
+ "prepare": "husky"
25
26
  },
26
27
  "keywords": [],
27
28
  "author": "Sinch <support@sinch.com> (https://www.sinch.com)",
@@ -35,6 +36,7 @@
35
36
  "files": [
36
37
  "dist/",
37
38
  "bin/",
39
+ "scripts/",
38
40
  "README.md",
39
41
  "LICENSE"
40
42
  ],
@@ -51,26 +53,24 @@
51
53
  "axios-retry": "^4.5.0",
52
54
  "blessed": "^0.1.81",
53
55
  "chalk": "^4.1.2",
54
- "cli-spinners": "^2.9.2",
55
56
  "cli-table3": "^0.6.3",
56
57
  "clipboardy": "^5.1.0",
57
58
  "commander": "^14.0.0",
58
59
  "form-data": "^4.0.0",
59
60
  "fs-extra": "^11.3.3",
60
- "google-libphonenumber": "^3.2.44",
61
- "inquirer": "8.2.7",
62
- "ora": "^4.1.1"
61
+ "google-libphonenumber": "^3.2.44"
63
62
  },
64
63
  "devDependencies": {
65
64
  "@types/adm-zip": "^0.5.7",
66
65
  "@types/blessed": "^0.1.25",
67
66
  "@types/fs-extra": "^11.0.4",
68
67
  "@types/google-libphonenumber": "^7.4.30",
69
- "@types/inquirer": "^9.0.9",
70
68
  "@types/jest": "^30.0.0",
71
69
  "@types/node": "24.10.9",
72
70
  "execa": "^5.1.1",
71
+ "husky": "^9.1.7",
73
72
  "jest": "^30.0.0",
73
+ "lint-staged": "^16.2.7",
74
74
  "nodemon": "^3.0.1",
75
75
  "prettier": "^3.8.1",
76
76
  "ts-jest": "^29.4.6",
@@ -81,5 +81,10 @@
81
81
  "engines": {
82
82
  "node": ">=20.0.0",
83
83
  "npm": ">=9.0.0"
84
+ },
85
+ "lint-staged": {
86
+ "*.{js,ts,json,md}": [
87
+ "prettier --write"
88
+ ]
84
89
  }
85
90
  }
@@ -0,0 +1,242 @@
1
+ # E2E Testing Documentation
2
+
3
+ ## Overview
4
+
5
+ End-to-end testing infrastructure for the Sinch CLI that tests the complete workflow against the real API.
6
+
7
+ ## What is Tested
8
+
9
+ The E2E test (`e2e-test.sh`) performs the following workflow:
10
+
11
+ 1. ✅ **CLI Installation** - Verifies CLI is installed and accessible
12
+ 2. ✅ **Authentication** - Checks credentials are configured
13
+ 3. ✅ **List Functions** - Verifies API connectivity
14
+ 4. ✅ **Init Function** - Creates a new function from template
15
+ 5. ✅ **Deploy Function** - Deploys function to production
16
+ 6. ✅ **Verify Deployment** - Confirms function is running
17
+ 7. ✅ **Check Status** - Validates function status endpoint
18
+ 8. ✅ **Check Logs** - Validates log streaming endpoint
19
+ 9. ✅ **Delete Function** - Cleans up test resources
20
+
21
+ ## Running E2E Tests
22
+
23
+ ### Option 1: GitLab CI (Automatic)
24
+
25
+ E2E tests run automatically on:
26
+
27
+ - Every push to `main` branch
28
+ - Every merge request
29
+
30
+ **Required GitLab CI Variables:**
31
+
32
+ - `E2E_KEY_ID` (protected)
33
+ - `E2E_KEY_SECRET` (protected, masked)
34
+ - `E2E_APPLICATION_KEY` (protected)
35
+ - `E2E_APPLICATION_SECRET` (protected, masked)
36
+
37
+ ### Option 2: Local with Node.js
38
+
39
+ ```bash
40
+ # Build and install CLI
41
+ npm run build
42
+ npm install -g .
43
+
44
+ # Set environment variables
45
+ export SINCH_KEY_ID="your-key-id"
46
+ export SINCH_KEY_SECRET="your-key-secret"
47
+ export SINCH_APPLICATION_KEY="your-app-key"
48
+ export SINCH_APPLICATION_SECRET="your-app-secret"
49
+
50
+ # Run E2E test
51
+ ./scripts/e2e-test.sh
52
+ ```
53
+
54
+ ### Option 3: Docker (Isolated Environment)
55
+
56
+ ```bash
57
+ # Build E2E Docker image
58
+ docker build -f Dockerfile.e2e -t sinch-cli-e2e .
59
+
60
+ # Run E2E tests in Docker
61
+ docker run --rm \
62
+ -e SINCH_KEY_ID="your-key-id" \
63
+ -e SINCH_KEY_SECRET="your-key-secret" \
64
+ -e SINCH_APPLICATION_KEY="your-app-key" \
65
+ -e SINCH_APPLICATION_SECRET="your-app-secret" \
66
+ sinch-cli-e2e
67
+ ```
68
+
69
+ **Docker Benefits:**
70
+
71
+ - Clean, isolated environment every run
72
+ - No local Node.js required
73
+ - Same environment as CI
74
+ - Great for testing before pushing
75
+
76
+ ## Test Output
77
+
78
+ The test script provides colored output:
79
+
80
+ - ✅ **Green** - Test passed
81
+ - ❌ **Red** - Test failed
82
+ - ⚠️ **Yellow** - Warning (non-fatal)
83
+
84
+ Example output:
85
+
86
+ ```
87
+ === Sinch CLI E2E Test ===
88
+
89
+ Test 1: CLI installation check
90
+ ✅ PASS: CLI installed (version: 0.1.0)
91
+
92
+ Test 2: Authentication check
93
+ ✅ PASS: Authentication OK
94
+
95
+ Test 3: List functions
96
+ ✅ PASS: List functions OK
97
+
98
+ ...
99
+
100
+ === E2E Test Complete ===
101
+ ✅ All tests passed!
102
+ ```
103
+
104
+ ## Credentials
105
+
106
+ ### CI/CD Environment
107
+
108
+ In GitLab CI, credentials are provided via environment variables configured in GitLab:
109
+
110
+ Settings → CI/CD → Variables
111
+
112
+ ### Local Development
113
+
114
+ Two options:
115
+
116
+ 1. **Environment Variables** (recommended for E2E):
117
+
118
+ ```bash
119
+ export SINCH_KEY_ID="..."
120
+ export SINCH_KEY_SECRET="..."
121
+ export SINCH_APPLICATION_KEY="..."
122
+ export SINCH_APPLICATION_SECRET="..."
123
+ ```
124
+
125
+ 2. **OS Keychain** (normal CLI usage):
126
+ ```bash
127
+ sinch auth login
128
+ # Credentials stored securely in OS keychain
129
+ ```
130
+
131
+ ## Troubleshooting
132
+
133
+ ### Test Fails: Authentication not configured
134
+
135
+ **Problem:** Environment variables not set
136
+
137
+ **Solution:**
138
+
139
+ ```bash
140
+ # Check env vars are set
141
+ echo $SINCH_KEY_ID
142
+ echo $SINCH_APPLICATION_KEY
143
+
144
+ # Set them if missing
145
+ export SINCH_KEY_ID="..."
146
+ export SINCH_KEY_SECRET="..."
147
+ export SINCH_APPLICATION_KEY="..."
148
+ export SINCH_APPLICATION_SECRET="..."
149
+ ```
150
+
151
+ ### Test Fails: Deploy timeout
152
+
153
+ **Problem:** API is slow or function deployment taking longer than expected
154
+
155
+ **Solution:** The test allows up to 2 minutes for deployment. If it times out:
156
+
157
+ 1. Check API status
158
+ 2. Check function complexity
159
+ 3. Try again (might be temporary)
160
+
161
+ ### Test Fails: Function not found after delete
162
+
163
+ **Problem:** This is actually expected - the test verifies deletion worked
164
+
165
+ **Solution:** No action needed, this is correct behavior
166
+
167
+ ## Cleanup
168
+
169
+ The E2E test **always cleans up** test functions, even on failure:
170
+
171
+ - Uses a `cleanup()` trap function
172
+ - Runs on EXIT, INT (Ctrl+C), or TERM signals
173
+ - Deletes test function by ID
174
+ - Safe to run multiple times
175
+
176
+ ## CI/CD Integration
177
+
178
+ ### Pipeline Stages
179
+
180
+ ```
181
+ ┌─────────┐
182
+ │ Build │ (26s)
183
+ └────┬────┘
184
+
185
+ ┌────▼────┐
186
+ │ Test:E2E│ (13s) ← New!
187
+ └────┬────┘
188
+
189
+ ┌────▼────┐
190
+ │ Publish │ (14s)
191
+ └─────────┘
192
+ ```
193
+
194
+ ### When Tests Run
195
+
196
+ - **Main branch:** Every commit
197
+ - **Merge requests:** Every push to MR
198
+ - **Tags:** No (only build + publish)
199
+
200
+ ### Failure Handling
201
+
202
+ - `allow_failure: false` - Pipeline **fails** if E2E test fails
203
+ - Prevents broken code from being published
204
+ - Forces fixes before merge
205
+
206
+ ## Best Practices
207
+
208
+ 1. **Run E2E before pushing:**
209
+
210
+ ```bash
211
+ npm run build
212
+ npm install -g .
213
+ ./scripts/e2e-test.sh
214
+ ```
215
+
216
+ 2. **Use Docker for final verification:**
217
+
218
+ ```bash
219
+ docker build -f Dockerfile.e2e -t sinch-cli-e2e .
220
+ docker run --rm -e SINCH_KEY_ID="..." ... sinch-cli-e2e
221
+ ```
222
+
223
+ 3. **Watch CI pipeline:**
224
+
225
+ ```bash
226
+ glab ci view
227
+ ```
228
+
229
+ 4. **Never commit with failing E2E tests** - The pipeline will block you anyway
230
+
231
+ ## Files
232
+
233
+ - `scripts/e2e-test.sh` - Main E2E test script
234
+ - `Dockerfile.e2e` - Docker image for isolated testing
235
+ - `.gitlab-ci.yml` - CI pipeline configuration
236
+ - `scripts/README-E2E.md` - This documentation
237
+
238
+ ## Support
239
+
240
+ - **Issues:** Report at GitLab project issues
241
+ - **CI Logs:** View in GitLab CI pipeline
242
+ - **Local Debugging:** Add `set -x` to script for verbose output
@@ -0,0 +1,155 @@
1
+ #!/bin/sh
2
+ set -e # Exit on error
3
+
4
+ echo "=== Sinch CLI E2E Test ==="
5
+ echo ""
6
+
7
+ # Generate unique test name
8
+ TEST_NAME="e2e-test"
9
+ FUNCTION_ID=""
10
+ FAILED=0
11
+
12
+ # Colors for output
13
+ RED='\033[0;31m'
14
+ GREEN='\033[0;32m'
15
+ YELLOW='\033[1;33m'
16
+ NC='\033[0m' # No Color
17
+
18
+ # Function to cleanup on exit
19
+ cleanup() {
20
+ EXIT_CODE=$?
21
+
22
+ if [ -n "$FUNCTION_ID" ]; then
23
+ echo ""
24
+ echo "${YELLOW}Cleaning up test function: $FUNCTION_ID${NC}"
25
+ sinch functions delete "$FUNCTION_ID" --force 2>/dev/null || true
26
+ fi
27
+
28
+ # If script exited with error and FAILED wasn't set, set it now
29
+ if [ $EXIT_CODE -ne 0 ] && [ $FAILED -eq 0 ]; then
30
+ FAILED=$EXIT_CODE
31
+ fi
32
+
33
+ if [ $FAILED -eq 0 ]; then
34
+ echo ""
35
+ echo "${GREEN}=== E2E Test Complete ===${NC}"
36
+ echo "${GREEN}All tests passed!${NC}"
37
+ else
38
+ echo ""
39
+ echo "${RED}=== E2E Test Failed ===${NC}"
40
+ echo "${RED}Exit code: $FAILED${NC}"
41
+ fi
42
+
43
+ exit $FAILED
44
+ }
45
+ trap cleanup EXIT INT TERM
46
+
47
+ # Test 1: Check CLI is installed
48
+ echo "Test 1: CLI installation check"
49
+ if ! command -v sinch >/dev/null 2>&1; then
50
+ echo "${RED}FAIL: sinch command not found${NC}"
51
+ FAILED=1
52
+ exit 1
53
+ fi
54
+ CLI_VERSION=$(sinch --version)
55
+ echo "${GREEN}PASS: CLI installed (version: $CLI_VERSION)${NC}"
56
+ echo ""
57
+
58
+ # Test 2: List functions (will fail if auth not configured via env vars)
59
+ echo "Test 2: List functions"
60
+ if ! sinch functions list >/dev/null 2>&1; then
61
+ echo "${RED}FAIL: Cannot list functions${NC}"
62
+ echo "Make sure these environment variables are set:"
63
+ echo " - SINCH_KEY_ID"
64
+ echo " - SINCH_KEY_SECRET"
65
+ echo " - SINCH_APPLICATION_KEY"
66
+ echo " - SINCH_APPLICATION_SECRET"
67
+ FAILED=1
68
+ exit 1
69
+ fi
70
+ echo "${GREEN}PASS: List functions OK${NC}"
71
+ echo ""
72
+
73
+ # Test 3: Init function
74
+ echo "Test 3: Initialize function"
75
+ TEST_DIR="/tmp/$TEST_NAME"
76
+ cd /tmp
77
+
78
+ if ! sinch functions init simple-voice-ivr \
79
+ --name "$TEST_NAME" \
80
+ --skip-install \
81
+ --non-interactive >/dev/null 2>&1; then
82
+ echo "${RED}FAIL: Function init failed${NC}"
83
+ FAILED=1
84
+ exit 1
85
+ fi
86
+ echo "${GREEN}PASS: Function initialized${NC}"
87
+ echo ""
88
+
89
+ # Test 4: Deploy function
90
+ echo "Test 4: Deploy function (this may take a minute...)"
91
+ cd "$TEST_NAME" || {
92
+ echo "${RED}FAIL: Cannot change to function directory${NC}"
93
+ FAILED=1
94
+ exit 1
95
+ }
96
+ DEPLOY_OUTPUT=$(sinch functions deploy --non-interactive 2>&1) || {
97
+ echo "${RED}FAIL: Deploy command failed${NC}"
98
+ echo "$DEPLOY_OUTPUT"
99
+ FAILED=1
100
+ exit 1
101
+ }
102
+
103
+ # Extract function ID from deploy output
104
+ FUNCTION_ID=$(echo "$DEPLOY_OUTPUT" | grep "Function ID:" | awk '{print $NF}')
105
+ if [ -z "$FUNCTION_ID" ]; then
106
+ echo "${RED}FAIL: Deploy succeeded but no function ID found${NC}"
107
+ echo "$DEPLOY_OUTPUT"
108
+ FAILED=1
109
+ exit 1
110
+ fi
111
+ echo "${GREEN}PASS: Function deployed (ID: $FUNCTION_ID)${NC}"
112
+ echo ""
113
+
114
+ # Test 5: Verify deployment
115
+ echo "Test 5: Verify function is accessible"
116
+ sleep 2 # Give it a moment to be fully available
117
+ if ! sinch functions list | grep -q "$TEST_NAME"; then
118
+ echo "${RED}FAIL: Function not found in list${NC}"
119
+ FAILED=1
120
+ exit 1
121
+ fi
122
+ echo "${GREEN}PASS: Function is accessible${NC}"
123
+ echo ""
124
+
125
+ # Test 6: Check function status
126
+ echo "Test 6: Check function status"
127
+ if ! sinch functions status "$FUNCTION_ID" >/dev/null 2>&1; then
128
+ echo "${YELLOW}WARN: Cannot fetch function status (non-fatal)${NC}"
129
+ else
130
+ echo "${GREEN}PASS: Function status OK${NC}"
131
+ fi
132
+ echo ""
133
+
134
+ # Test 7: Check logs are accessible
135
+ echo "Test 7: Check logs are accessible"
136
+ if ! sinch functions logs "$FUNCTION_ID" --limit 10 >/dev/null 2>&1; then
137
+ echo "${YELLOW}WARN: Cannot fetch logs (non-fatal)${NC}"
138
+ else
139
+ echo "${GREEN}PASS: Logs accessible${NC}"
140
+ fi
141
+ echo ""
142
+
143
+ # Test 8: Delete function (cleanup will also handle this)
144
+ echo "Test 8: Delete function"
145
+ if ! sinch functions delete "$FUNCTION_ID" --force >/dev/null 2>&1; then
146
+ echo "${RED}FAIL: Delete failed${NC}"
147
+ FAILED=1
148
+ exit 1
149
+ fi
150
+ echo "${GREEN}PASS: Function deleted${NC}"
151
+ FUNCTION_ID="" # Clear so cleanup doesn't try again
152
+ echo ""
153
+
154
+ # All tests passed
155
+ exit 0
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+
6
+ async function postBuild() {
7
+ console.log('Running post-build tasks...');
8
+
9
+ // Ensure dist directory exists
10
+ await fs.ensureDir('dist');
11
+
12
+ // Copy package.json to dist for runtime requires
13
+ await fs.copy('package.json', 'dist/package.json');
14
+
15
+ // Copy skills folder to dist
16
+ const skillsSource = path.join(__dirname, '..', 'skills');
17
+ const skillsDest = path.join(__dirname, '..', 'dist', 'skills');
18
+ if (await fs.pathExists(skillsSource)) {
19
+ await fs.copy(skillsSource, skillsDest, { overwrite: true });
20
+ console.log('Copied skills to dist/skills');
21
+ }
22
+
23
+ // Ensure the CLI entry point works
24
+ const distIndex = path.join('dist', 'index.js');
25
+ if (await fs.pathExists(distIndex)) {
26
+ // Add shebang if not present
27
+ const content = await fs.readFile(distIndex, 'utf8');
28
+ if (!content.startsWith('#!/usr/bin/env node')) {
29
+ await fs.writeFile(distIndex, '#!/usr/bin/env node\n' + content);
30
+ }
31
+ }
32
+
33
+ console.log('Post-build tasks completed.');
34
+ }
35
+
36
+ postBuild().catch(console.error);
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * postinstall.js — runs on `npm install -g @sinch/cli`
5
+ *
6
+ * 1. Writes ~/.sinch/completions.json (command tree for PowerShell)
7
+ * 2. Installs shell completion into the user's profile (PowerShell, zsh, or bash)
8
+ *
9
+ * Silent failure on ALL errors — must never break `npm install`.
10
+ *
11
+ * Keep COMPLETION_COMMANDS in sync with src/index.ts.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+ const { spawn } = require('child_process');
18
+
19
+ const SINCH_DIR = path.join(os.homedir(), '.sinch');
20
+
21
+ // Keep in sync with COMPLETION_COMMANDS in src/index.ts.
22
+ // The postAction hook overwrites completions.json on every CLI run, so drift self-heals.
23
+ const COMPLETION_COMMANDS = {
24
+ functions: ['init', 'list', 'deploy', 'download', 'dev', 'status', 'logs', 'delete', 'docs'],
25
+ templates: ['list', 'show', 'node', 'csharp', 'python'],
26
+ voice: ['callback-url', 'get-callbacks', 'set-callback'],
27
+ secrets: ['list', 'add', 'get', 'delete', 'clear'],
28
+ auth: ['login', 'status', 'logout'],
29
+ sip: ['trunks', 'endpoints', 'acls', 'countries', 'credential-lists', 'calls'],
30
+ numbers: ['active', 'available', 'regions'],
31
+ fax: ['send', 'list', 'get', 'cancel', 'auth-status', 'status'],
32
+ conversation: ['send', 'messages', 'contacts', 'conversations', 'apps', 'webhooks'],
33
+ skills: ['install', 'list', 'uninstall', 'update'],
34
+ config: ['--set', '--get', '--list'],
35
+ completion: ['--shell', '--install'],
36
+ };
37
+
38
+ const SENTINEL_START = '# ── Sinch CLI completion ──';
39
+ const SENTINEL_END = '# ── End Sinch CLI completion ──';
40
+
41
+ function getVersion() {
42
+ try {
43
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
44
+ return pkg.version || '0.0.0';
45
+ } catch {
46
+ return '0.0.0';
47
+ }
48
+ }
49
+
50
+ function writeCompletionsJson(version) {
51
+ const filePath = path.join(SINCH_DIR, 'completions.json');
52
+ fs.writeFileSync(filePath, JSON.stringify({ version, commands: COMPLETION_COMMANDS }, null, 2));
53
+ }
54
+
55
+ function getPowerShellCompletionScript() {
56
+ return `${SENTINEL_START}
57
+ # PowerShell completion for Sinch CLI
58
+ # Reads command tree from ~/.sinch/completions.json (auto-updated by CLI)
59
+
60
+ Register-ArgumentCompleter -Native -CommandName sinch -ScriptBlock {
61
+ param($wordToComplete, $commandAst, $cursorPosition)
62
+
63
+ try {
64
+ $jsonPath = Join-Path $env:USERPROFILE '.sinch' 'completions.json'
65
+ if (-not (Test-Path $jsonPath)) { return }
66
+
67
+ $data = Get-Content $jsonPath -Raw | ConvertFrom-Json
68
+ $line = $commandAst.CommandElements
69
+ $command = if ($line.Count -gt 1) { $line[1].Value } else { "" }
70
+
71
+ if ($line.Count -eq 2) {
72
+ $mainCommands = @($data.commands.PSObject.Properties.Name) + @('--version', '--help')
73
+ $mainCommands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
74
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
75
+ }
76
+ }
77
+ elseif ($line.Count -eq 3 -and $data.commands.$command) {
78
+ @($data.commands.$command) | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
79
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ # Silently fail — prevents PowerShell startup errors
85
+ }
86
+ }
87
+ ${SENTINEL_END}`;
88
+ }
89
+
90
+ // --- Profile installation helpers ---
91
+
92
+ function shellEscapePath(p) {
93
+ return "'" + p.replace(/'/g, "'\\''") + "'";
94
+ }
95
+
96
+ function upsertBlock(content, block) {
97
+ const pattern =
98
+ SENTINEL_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
99
+ '[\\s\\S]*?' +
100
+ SENTINEL_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
101
+ const replaced = content.replace(new RegExp(pattern, 'g'), block.trim());
102
+ if (replaced !== content) return replaced;
103
+ return content + (content && !content.endsWith('\n') ? '\n' : '') + '\n' + block.trim() + '\n';
104
+ }
105
+
106
+ function tryShell(cmd) {
107
+ return new Promise((resolve) => {
108
+ const ps = spawn(cmd, ['-NoProfile', '-Command', '$PROFILE'], {
109
+ stdio: ['ignore', 'pipe', 'pipe'],
110
+ });
111
+ let out = '';
112
+ ps.stdout.on('data', (d) => (out += d.toString()));
113
+ ps.on('close', (code) => resolve(code === 0 ? out.trim() : null));
114
+ ps.on('error', () => resolve(null));
115
+ });
116
+ }
117
+
118
+ async function installPowerShellCompletion() {
119
+ const completionFile = path.join(SINCH_DIR, 'sinch-completion.ps1');
120
+ fs.writeFileSync(completionFile, getPowerShellCompletionScript());
121
+
122
+ const profilePath = (await tryShell('pwsh')) ?? (await tryShell('powershell'));
123
+ if (!profilePath) return;
124
+
125
+ // Validate profile path is within user's home directory
126
+ const resolved = path.resolve(profilePath);
127
+ if (!resolved.startsWith(os.homedir())) return;
128
+
129
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
130
+
131
+ let content = '';
132
+ try {
133
+ content = fs.readFileSync(resolved, 'utf8');
134
+ } catch {
135
+ // Profile doesn't exist yet — will be created
136
+ }
137
+
138
+ const sourceLine = `. ${shellEscapePath(completionFile.replace(/\\/g, '\\\\'))}`;
139
+ const block = `${SENTINEL_START}\n${sourceLine}\n${SENTINEL_END}`;
140
+ fs.writeFileSync(resolved, upsertBlock(content, block));
141
+ }
142
+
143
+ function installBashZshCompletion() {
144
+ const completionFile = path.join(SINCH_DIR, 'sinch-completion.bash');
145
+ const sourceLine = `source ${shellEscapePath(completionFile)}`;
146
+ const block = `${SENTINEL_START}\n${sourceLine}\n${SENTINEL_END}`;
147
+
148
+ const home = os.homedir();
149
+ const zshrc = path.join(home, '.zshrc');
150
+ const bashrc = path.join(home, '.bashrc');
151
+
152
+ const rcFiles = [];
153
+ if (process.platform === 'darwin') {
154
+ rcFiles.push(zshrc);
155
+ if (fs.existsSync(bashrc)) rcFiles.push(bashrc);
156
+ } else {
157
+ if (fs.existsSync(bashrc)) rcFiles.push(bashrc);
158
+ if (fs.existsSync(zshrc)) rcFiles.push(zshrc);
159
+ if (rcFiles.length === 0) rcFiles.push(bashrc);
160
+ }
161
+
162
+ for (const rcFile of rcFiles) {
163
+ let content = '';
164
+ try {
165
+ content = fs.readFileSync(rcFile, 'utf8');
166
+ } catch {
167
+ // File doesn't exist yet — will be created
168
+ }
169
+ fs.writeFileSync(rcFile, upsertBlock(content, block));
170
+ }
171
+ }
172
+
173
+ // --- Main ---
174
+
175
+ async function main() {
176
+ // Only run on global installs or when forced by setup-dev.js
177
+ if (process.env.npm_config_global !== 'true' && !process.env.SINCH_FORCE_POSTINSTALL) {
178
+ return;
179
+ }
180
+
181
+ try {
182
+ fs.mkdirSync(SINCH_DIR, { recursive: true });
183
+
184
+ const version = getVersion();
185
+ writeCompletionsJson(version);
186
+
187
+ if (process.platform === 'win32') {
188
+ await installPowerShellCompletion();
189
+ } else {
190
+ installBashZshCompletion();
191
+ }
192
+ } catch {
193
+ // Silent failure — never break npm install
194
+ }
195
+ }
196
+
197
+ main();