sloss-cli 1.1.0 → 1.2.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # sloss-cli
2
2
 
3
- Command-line interface for [Sloss](https://github.com/aualdrich/sloss) — a self-hosted IPA/APK distribution server inspired by Diawi.
3
+ Command-line interface for [Sloss](https://github.com/aualdrich/sloss) — a self-hosted build distribution server for Expo apps. Supports iOS and Android.
4
4
 
5
5
  ## Install
6
6
 
@@ -11,43 +11,167 @@ npm install -g sloss-cli
11
11
  Or as a project dependency:
12
12
 
13
13
  ```bash
14
- npm install sloss-cli
14
+ npm install --save-dev sloss-cli
15
15
  # or
16
- bun add sloss-cli
16
+ bun add --dev sloss-cli
17
17
  ```
18
18
 
19
- ## Quick Start
19
+ ## Setup
20
+
21
+ ### 1. Create a Sloss account
22
+
23
+ Sign up at your team's Sloss instance (e.g. `https://sloss.example.com/signup`). After signing up, you'll receive an API key on the onboarding page. You can also find it under **Settings → API Key**.
24
+
25
+ ### 2. Authenticate the CLI
20
26
 
21
27
  ```bash
22
- # Authenticate (saves API key to ~/.config/sloss/credentials.json)
23
28
  sloss login
29
+ ```
30
+
31
+ This prompts for your email and password, fetches your API key, and saves it to `~/.config/sloss/credentials.json`. You only need to do this once per machine.
32
+
33
+ Alternatively, set the `SLOSS_API_KEY` environment variable or pass `--api-key` to any command.
34
+
35
+ ### 3. Add `.sloss.json` to your Expo project
36
+
37
+ Create a `.sloss.json` file in your Expo project root (next to `app.json`):
38
+
39
+ ```json
40
+ {
41
+ "type": "expo",
42
+ "app_name": "MyApp",
43
+ "bundle_id": "com.mycompany.myapp",
44
+ "version_file": "app.version.json",
45
+ "profiles": {
46
+ "development": {
47
+ "bundle_id_suffix": ".dev"
48
+ },
49
+ "preview": {
50
+ "bundle_id_suffix": ".preview"
51
+ },
52
+ "production": {
53
+ "bundle_id_suffix": ""
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ | Field | Description |
60
+ |-------|-------------|
61
+ | `type` | Always `"expo"` |
62
+ | `app_name` | Display name for the build on Sloss |
63
+ | `bundle_id` | Base bundle identifier |
64
+ | `version_file` | Path to a JSON file containing `version` and `buildNumber` (relative to project root) |
65
+ | `profiles` | Build profile config — `bundle_id_suffix` is appended to `bundle_id` per profile |
66
+
67
+ ### 4. Create `app.version.json`
68
+
69
+ Sloss reads version info from a dedicated file so the build agent can bump versions independently:
70
+
71
+ ```json
72
+ {
73
+ "version": "1.0.0",
74
+ "buildNumber": "1"
75
+ }
76
+ ```
77
+
78
+ Reference this in your `app.json` / `app.config.js` so Expo picks it up:
79
+
80
+ ```js
81
+ // app.config.js
82
+ const version = require('./app.version.json');
83
+
84
+ module.exports = {
85
+ expo: {
86
+ version: version.version,
87
+ ios: { buildNumber: String(version.buildNumber) },
88
+ android: { versionCode: parseInt(version.buildNumber, 10) },
89
+ // ... rest of your config
90
+ },
91
+ };
92
+ ```
93
+
94
+ ## Usage
95
+
96
+ ### Queue a build
97
+
98
+ From your Expo project directory:
99
+
100
+ ```bash
101
+ # iOS development build (default)
102
+ sloss build
103
+
104
+ # Android preview build
105
+ sloss build --platform android --profile preview
106
+
107
+ # Production build with minor version bump
108
+ sloss build --profile production --bump minor
109
+ ```
110
+
111
+ The CLI packages your project via `git archive`, uploads it to Sloss, and a build agent picks it up. You'll get a link to the build page with live logs.
112
+
113
+ #### Build options
114
+
115
+ | Flag | Default | Description |
116
+ |------|---------|-------------|
117
+ | `--platform <platform>` | `ios` | `ios` or `android` |
118
+ | `--profile <profile>` | `development` | `development`, `preview`, or `production` |
119
+ | `--bump <type>` | `patch` | Version bump for production builds: `patch`, `minor`, or `major` |
120
+ | `--dir <path>` | `.` | Project directory (if not running from project root) |
24
121
 
25
- # List recent builds
122
+ ### List builds
123
+
124
+ ```bash
26
125
  sloss list
126
+ sloss list --limit 20
127
+ ```
128
+
129
+ ### Get build details
27
130
 
131
+ ```bash
132
+ sloss info <build-id>
133
+ ```
134
+
135
+ ### Upload a pre-built artifact
136
+
137
+ If you already have an IPA or APK (e.g. from a local build):
138
+
139
+ ```bash
28
140
  # Upload an IPA
29
- sloss upload path/to/App.ipa --platform ios --profile preview
141
+ sloss upload ./build/MyApp.ipa --platform ios --profile preview
30
142
 
31
143
  # Upload an APK
32
- sloss upload path/to/app.apk --platform android --profile development
144
+ sloss upload ./build/app-release.apk --platform android --profile production
145
+ ```
33
146
 
34
- # Get build details
35
- sloss info <build-id>
147
+ #### Upload options
36
148
 
37
- # Delete a build
149
+ | Flag | Description |
150
+ |------|-------------|
151
+ | `--platform <platform>` | **Required.** `ios` or `android` |
152
+ | `--profile <profile>` | Build profile: `development`, `preview`, or `production` |
153
+ | `--app-name <name>` | App display name |
154
+ | `--version <version>` | Version string |
155
+ | `--build-number <number>` | Build number |
156
+
157
+ ### Delete a build
158
+
159
+ ```bash
38
160
  sloss delete <build-id>
39
161
  ```
40
162
 
41
163
  ## Authentication
42
164
 
43
165
  API key resolution order (highest → lowest priority):
166
+
44
167
  1. `--api-key` CLI flag
45
168
  2. `SLOSS_API_KEY` environment variable
46
169
  3. `~/.config/sloss/credentials.json` (saved by `sloss login`)
47
170
 
48
171
  ## Server URL
49
172
 
50
- URL resolution order:
173
+ URL resolution order (highest → lowest priority):
174
+
51
175
  1. `--url` CLI flag
52
176
  2. `SLOSS_URL` environment variable
53
177
  3. `~/.config/sloss/credentials.json`
@@ -55,40 +179,53 @@ URL resolution order:
55
179
 
56
180
  ## Global Options
57
181
 
58
- ```
59
- --api-key <key> Override API key
60
- --url <url> Override server URL
61
- --json Output as JSON
62
- --version Show version
63
- --help Show help
182
+ | Flag | Description |
183
+ |------|-------------|
184
+ | `--api-key <key>` | Override API key |
185
+ | `--url <url>` | Override server URL |
186
+ | `--json` | Output as JSON |
187
+ | `--version` | Show CLI version |
188
+ | `--help` | Show help |
189
+
190
+ ## How It Works
191
+
192
+ 1. `sloss build` reads `.sloss.json` from your project root
193
+ 2. Your committed source is packaged into a tarball via `git archive`
194
+ 3. The tarball is uploaded to your Sloss server
195
+ 4. A build agent picks up the job and builds it locally using Xcode (iOS) or Gradle (Android)
196
+ 5. The finished IPA/APK is uploaded back to Sloss
197
+ 6. You get a build page with install links and QR codes for on-device installation
198
+
199
+ ## OpenClaw Skill
200
+
201
+ This package includes an [OpenClaw](https://openclaw.ai) skill so your AI agent can use the Sloss CLI. After installing `sloss-cli`, add the skill directory to your OpenClaw config:
202
+
203
+ ```json5
204
+ // ~/.openclaw/openclaw.json
205
+ {
206
+ skills: {
207
+ load: {
208
+ extraDirs: ["./node_modules/sloss-cli/skills"]
209
+ }
210
+ }
211
+ }
64
212
  ```
65
213
 
66
- ## Publishing to npm
67
-
68
- Prerequisites:
69
- - An [npm](https://www.npmjs.com) account with publish access
70
- - An npm access token (Granular token with "Bypass 2FA" enabled, or an Automation classic token)
214
+ Or copy the skill into your workspace:
71
215
 
72
216
  ```bash
73
- # Set your npm token
74
- echo "//registry.npmjs.org/:_authToken=YOUR_TOKEN" > ~/.npmrc
75
-
76
- # Bump version (patch/minor/major)
77
- npm version patch
78
-
79
- # Publish
80
- npm publish --access public
217
+ cp -r ./node_modules/sloss-cli/skills/sloss <workspace>/skills/sloss
81
218
  ```
82
219
 
83
- The package is published as [`sloss-cli`](https://www.npmjs.com/package/sloss-cli) under the `front-porch-software` npm account.
220
+ The skill is also available on [ClawHub](https://clawhub.com):
84
221
 
85
- ### What gets published
86
-
87
- Only `bin/`, `src/`, and `README.md` are included in the npm package (controlled by `files` in `package.json`). Build scripts and other development files are excluded.
222
+ ```bash
223
+ clawhub install sloss
224
+ ```
88
225
 
89
226
  ## Related
90
227
 
91
- - **[Sloss Server](https://github.com/aualdrich/sloss)** — The distribution server that this CLI talks to
228
+ - **[Sloss Server](https://github.com/aualdrich/sloss)** — The self-hosted distribution server
92
229
 
93
230
  ## License
94
231
 
package/bin/sloss.js CHANGED
@@ -12,6 +12,7 @@ import { uploadCommand } from '../src/commands/upload.js';
12
12
  import { infoCommand } from '../src/commands/info.js';
13
13
  import { deleteCommand } from '../src/commands/delete.js';
14
14
  import { buildCommand } from '../src/commands/build.js';
15
+ import { initCommand } from '../src/commands/init.js';
15
16
 
16
17
  const program = new Command();
17
18
 
@@ -23,6 +24,21 @@ program
23
24
  .option('--url <url>', 'Server URL (overrides SLOSS_URL env var)')
24
25
  .option('--json', 'Output as JSON instead of human-readable format');
25
26
 
27
+ // Init command
28
+ program
29
+ .command('init')
30
+ .description('Set up a project for Sloss builds')
31
+ .option('--dir <path>', 'Project directory (default: current directory)')
32
+ .option('--url <url>', 'Server URL')
33
+ .action(async (options) => {
34
+ try {
35
+ await initCommand({ ...options, url: program.opts().url || options.url });
36
+ } catch (error) {
37
+ console.error(`Error: ${error.message}`);
38
+ process.exit(1);
39
+ }
40
+ });
41
+
26
42
  // Login command
27
43
  program
28
44
  .command('login')
package/package.json CHANGED
@@ -1,22 +1,33 @@
1
1
  {
2
2
  "name": "sloss-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "CLI for Sloss — a self-hosted IPA/APK distribution server",
5
5
  "type": "module",
6
6
  "bin": {
7
- "sloss": "./bin/sloss.js"
7
+ "sloss": "bin/sloss.js"
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
11
  "src/",
12
+ "skills/",
12
13
  "README.md"
13
14
  ],
14
- "keywords": ["sloss", "ios", "android", "ipa", "apk", "build", "distribution", "testflight", "diawi"],
15
+ "keywords": [
16
+ "sloss",
17
+ "ios",
18
+ "android",
19
+ "ipa",
20
+ "apk",
21
+ "build",
22
+ "distribution",
23
+ "testflight",
24
+ "diawi"
25
+ ],
15
26
  "author": "Front Porch Software",
16
27
  "license": "MIT",
17
28
  "repository": {
18
29
  "type": "git",
19
- "url": "https://github.com/aualdrich/sloss-cli.git"
30
+ "url": "git+https://github.com/aualdrich/sloss-cli.git"
20
31
  },
21
32
  "engines": {
22
33
  "node": ">=18.0.0"
@@ -0,0 +1,136 @@
1
+ ---
2
+ name: sloss
3
+ description: Manage Sloss — the local IPA/APK distribution server for Switchboard. Use when Austin asks to check Sloss status, restart it, view builds, get an install link, or upload an IPA manually.
4
+ ---
5
+
6
+ # Sloss — IPA/APK Distribution Server
7
+
8
+ Sloss is a lightweight self-hosted build distribution server, inspired by Diawi. Named after [Sloss Furnaces](https://www.slossfurnaces.com/) in Birmingham, AL.
9
+
10
+ ## Repos
11
+
12
+ | Repo | Purpose | Local path |
13
+ |------|---------|------------|
14
+ | [aualdrich/sloss](https://github.com/aualdrich/sloss) | Build server (Node.js/Express) | `~/sloss/` |
15
+ | [aualdrich/sloss-cli](https://github.com/aualdrich/sloss-cli) | CLI (`sloss` command) | `~/sloss/cli/` |
16
+
17
+ > **Note:** `cli/` is a separate repo cloned inside `~/sloss/` for convenience. It is **not** tracked by the sloss server repo (gitignored). Always commit/push CLI changes from `~/sloss/cli/`.
18
+
19
+ ## Server Setup
20
+
21
+ - **Port:** `3001`
22
+ - **Public URL:** `https://sloss.ngrok.app` (via ngrok LaunchAgent)
23
+ - **LaunchAgent:** `com.switchboard.sloss` — auto-starts at login, KeepAlive
24
+ - **Log:** `/tmp/sloss.log`
25
+ - **Auth:** Cognito JWT (web UI) + per-user API keys (CLI/API)
26
+
27
+ ## Server Status & Restart
28
+
29
+ ```bash
30
+ # Check if running
31
+ curl -s http://localhost:3001/ | head -3
32
+
33
+ # View logs
34
+ tail -f /tmp/sloss.log
35
+
36
+ # Restart
37
+ launchctl unload ~/Library/LaunchAgents/com.switchboard.sloss.plist
38
+ launchctl load ~/Library/LaunchAgents/com.switchboard.sloss.plist
39
+ ```
40
+
41
+ ## CLI Usage
42
+
43
+ The `sloss` CLI lives in `~/sloss/cli/`. To use it from anywhere, run `node ~/sloss/cli/bin/sloss.js` or install it globally.
44
+
45
+ ### Authentication
46
+
47
+ ```bash
48
+ # Login once — saves API key to ~/.config/sloss/credentials.json
49
+ sloss login
50
+ # → prompts for email + password, fetches API key, saves credentials
51
+ ```
52
+
53
+ API key resolution order (highest → lowest priority):
54
+ 1. `--api-key` flag
55
+ 2. `SLOSS_API_KEY` env var
56
+ 3. `~/.config/sloss/credentials.json`
57
+
58
+ ### Commands
59
+
60
+ ```bash
61
+ sloss login # Authenticate + save credentials
62
+ sloss build [options] # Queue a build via the Sloss build agent
63
+ sloss list [--limit 20] # List recent builds
64
+ sloss info <build-id> # Details for a specific build
65
+ sloss upload <file> --platform ios # Upload an IPA or APK manually
66
+ sloss delete <build-id> # Delete a build
67
+
68
+ # Build options
69
+ sloss build --profile development # Build profile (development|preview|production)
70
+ sloss build --platform ios # Platform (ios|android), default: ios
71
+ sloss build --bump minor # Version bump type (patch|minor|major), default: patch
72
+ sloss build --dir /path/to/project # Project directory, default: current dir
73
+
74
+ # Global flags (all commands)
75
+ sloss --url <url> <command> # Override server URL
76
+ sloss --api-key <key> <command> # Override API key
77
+ sloss --json <command> # JSON output
78
+ ```
79
+
80
+ ### Build Flow
81
+
82
+ 1. CLI reads `.sloss.json` from the project root
83
+ 2. Tars up committed source via `git archive` (respects `.gitignore`)
84
+ 3. Uploads tarball to `POST /api/builds/start` with your per-user API key
85
+ 4. Build agent (mac-mini) picks up the job, extracts, builds locally via Xcode
86
+ 5. Agent uploads the IPA/APK artifact to Sloss when done
87
+ 6. Build page with live logs at `https://sloss.ngrok.app/builds/<id>`
88
+
89
+ ## Manual Upload via curl
90
+
91
+ ```bash
92
+ curl -s -X POST https://sloss.ngrok.app/upload \
93
+ -H "Authorization: Bearer <your-api-key>" \
94
+ -F "ipa=@/path/to/App.ipa" \
95
+ -F "app_name=Switchboard" \
96
+ -F "bundle_id=com.aualdrich1.switchboard.preview" \
97
+ -F "version=1.0.0" \
98
+ -F "build_number=36" \
99
+ -F "profile=preview"
100
+ ```
101
+
102
+ **profile** values: `development` · `preview` · `production`
103
+
104
+ ## Install on Device
105
+
106
+ After a build, Sloss outputs:
107
+ ```
108
+ 🏭 Sloss build page : https://sloss.ngrok.app/b/<uuid>
109
+ 📲 Install URL : itms-services://...
110
+ ```
111
+
112
+ To install on iPhone:
113
+ 1. Open the `itms-services://...` URL in **Safari** (not Chrome), or
114
+ 2. Open the build page URL in Safari and tap **Install**
115
+
116
+ ## API Endpoints
117
+
118
+ | Method | Path | Auth | Description |
119
+ |--------|-----------------------|------|------------------------------|
120
+ | POST | `/upload` | Yes | Upload an IPA or APK |
121
+ | GET | `/b/:id` | No | Build landing page |
122
+ | GET | `/m/:id` | No | Apple OTA manifest plist |
123
+ | GET | `/ipa/:id` | No | Raw IPA download |
124
+ | GET | `/` | No | All builds, newest first |
125
+ | POST | `/auth/login` | No | Login → Cognito JWT |
126
+ | GET | `/api-keys` | JWT | Get current user's API key |
127
+ | POST | `/api-keys/generate` | JWT | Generate new API key |
128
+ | POST | `/api-keys/regenerate`| JWT | Replace existing API key |
129
+
130
+ ## Build Agent
131
+
132
+ The Sloss build agent runs as a LaunchAgent (`com.switchboard.sloss-agent`) on the Mac Mini, polling for queued jobs every 10s. It extracts the tarball, runs `eas build --local`, and uploads the resulting IPA/APK back to Sloss.
133
+
134
+ - **LaunchAgent:** `com.switchboard.sloss-agent`
135
+ - **Log:** `/Users/administrator/Library/Logs/sloss-agent.log`
136
+ - **Agent code:** `~/sloss/agent/`
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Init command - Set up a project for Sloss builds
3
+ *
4
+ * Flow:
5
+ * 1. Check if user is logged in, prompt login if not
6
+ * 2. Detect project type (Expo, React Native, etc.)
7
+ * 3. Auto-detect app name and bundle ID from project config
8
+ * 4. Prompt user to confirm / override detected values
9
+ * 5. Generate .sloss.json with conventional defaults
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { homedir } from 'os';
15
+ import { createInterface } from 'readline';
16
+ import { loginCommand } from './login.js';
17
+
18
+ const CREDENTIALS_PATH = join(homedir(), '.config', 'sloss', 'credentials.json');
19
+
20
+ function prompt(question, defaultValue = '') {
21
+ return new Promise((resolve) => {
22
+ const rl = createInterface({
23
+ input: process.stdin,
24
+ output: process.stdout,
25
+ });
26
+ const suffix = defaultValue ? ` (${defaultValue})` : '';
27
+ rl.question(`${question}${suffix}: `, (answer) => {
28
+ rl.close();
29
+ resolve(answer.trim() || defaultValue);
30
+ });
31
+ });
32
+ }
33
+
34
+ function promptYesNo(question, defaultYes = true) {
35
+ return new Promise((resolve) => {
36
+ const rl = createInterface({
37
+ input: process.stdin,
38
+ output: process.stdout,
39
+ });
40
+ const hint = defaultYes ? 'Y/n' : 'y/N';
41
+ rl.question(`${question} (${hint}): `, (answer) => {
42
+ rl.close();
43
+ const val = answer.trim().toLowerCase();
44
+ if (val === '') resolve(defaultYes);
45
+ else resolve(val === 'y' || val === 'yes');
46
+ });
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Detect project type and extract app metadata from config files.
52
+ */
53
+ function detectProject(projectDir) {
54
+ const detected = {
55
+ type: null,
56
+ appName: null,
57
+ bundleId: null,
58
+ };
59
+
60
+ // Try Expo: app.json
61
+ const appJsonPath = join(projectDir, 'app.json');
62
+ if (existsSync(appJsonPath)) {
63
+ try {
64
+ const appJson = JSON.parse(readFileSync(appJsonPath, 'utf8'));
65
+ const expo = appJson.expo || appJson;
66
+ detected.type = 'expo';
67
+ detected.appName = expo.name || null;
68
+ detected.bundleId = expo.ios?.bundleIdentifier || expo.android?.package || null;
69
+ } catch { /* ignore parse errors */ }
70
+ }
71
+
72
+ // Try Expo: app.config.js (parse statically — look for common patterns)
73
+ if (!detected.bundleId) {
74
+ const appConfigJsPath = join(projectDir, 'app.config.js');
75
+ if (existsSync(appConfigJsPath)) {
76
+ try {
77
+ const src = readFileSync(appConfigJsPath, 'utf8');
78
+ detected.type = 'expo';
79
+
80
+ // Extract base app name (the production one, no Dev/Preview suffix)
81
+ const allNames = [...src.matchAll(/return\s+['"]([^'"]+)['"]/g)]
82
+ .map(m => m[1])
83
+ .filter(n => !n.includes('/') && !n.includes('.') && n.length < 50);
84
+ const prodName = allNames.find(n => !/ Dev$| Preview$| Staging$/i.test(n))
85
+ || allNames[allNames.length - 1];
86
+ if (prodName && !detected.appName) {
87
+ detected.appName = prodName;
88
+ }
89
+ if (!detected.appName) {
90
+ const nameMatch = src.match(/name:\s*['"]([^'"]+)['"]/m);
91
+ if (nameMatch) detected.appName = nameMatch[1];
92
+ }
93
+
94
+ // Extract base bundle ID (production, no suffix)
95
+ const bundleMatch = src.match(/return\s+['"]([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*){2,})['"]/m)
96
+ || src.match(/bundleIdentifier.*?['"]([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*){2,})['"]/m);
97
+ if (bundleMatch) {
98
+ // Try to find the base (production) bundle ID — the one without .dev or .preview
99
+ const allBundles = [...src.matchAll(/['"]([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*){2,})['"]/g)]
100
+ .map(m => m[1])
101
+ .filter(id => !id.endsWith('.dev') && !id.endsWith('.preview'));
102
+ detected.bundleId = allBundles[0] || bundleMatch[1];
103
+ }
104
+ } catch { /* ignore */ }
105
+ }
106
+ }
107
+
108
+ // Try eas.json for profile detection
109
+ const easJsonPath = join(projectDir, 'eas.json');
110
+ if (existsSync(easJsonPath)) {
111
+ detected.type = detected.type || 'expo';
112
+ }
113
+
114
+ // Try React Native (no Expo): look for android/app/build.gradle or ios/*.xcodeproj
115
+ if (!detected.type) {
116
+ if (existsSync(join(projectDir, 'android', 'app', 'build.gradle')) ||
117
+ existsSync(join(projectDir, 'ios'))) {
118
+ detected.type = 'react-native';
119
+ }
120
+ }
121
+
122
+ // Try package.json for app name fallback
123
+ if (!detected.appName) {
124
+ const pkgPath = join(projectDir, 'package.json');
125
+ if (existsSync(pkgPath)) {
126
+ try {
127
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
128
+ detected.appName = pkg.name || null;
129
+ } catch { /* ignore */ }
130
+ }
131
+ }
132
+
133
+ return detected;
134
+ }
135
+
136
+ /**
137
+ * Detect build profiles from eas.json
138
+ */
139
+ function detectProfiles(projectDir) {
140
+ const easJsonPath = join(projectDir, 'eas.json');
141
+ if (!existsSync(easJsonPath)) return null;
142
+
143
+ try {
144
+ const easJson = JSON.parse(readFileSync(easJsonPath, 'utf8'));
145
+ const build = easJson.build || {};
146
+ const profiles = {};
147
+
148
+ for (const [name, config] of Object.entries(build)) {
149
+ const env = config.env || {};
150
+ const variant = env.APP_VARIANT || name;
151
+
152
+ // Convention: dev → .dev suffix, preview → .preview, production → no suffix
153
+ let suffix = '';
154
+ if (variant === 'development' || variant === 'dev') suffix = '.dev';
155
+ else if (variant === 'preview' || variant === 'staging') suffix = '.preview';
156
+
157
+ profiles[name] = { bundle_id_suffix: suffix };
158
+ }
159
+
160
+ return Object.keys(profiles).length > 0 ? profiles : null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ export async function initCommand(options) {
167
+ const projectDir = options.dir || process.cwd();
168
+
169
+ console.log('');
170
+ console.log('🏭 Sloss — Project Setup');
171
+ console.log('════════════════════════════════════════');
172
+ console.log('');
173
+
174
+ // Check for existing .sloss.json
175
+ const slossPath = join(projectDir, '.sloss.json');
176
+ if (existsSync(slossPath)) {
177
+ const overwrite = await promptYesNo('.sloss.json already exists. Overwrite?', false);
178
+ if (!overwrite) {
179
+ console.log('Aborted.');
180
+ return;
181
+ }
182
+ }
183
+
184
+ // Step 1: Check login
185
+ let isLoggedIn = false;
186
+ try {
187
+ const creds = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
188
+ if (creds.apiKey) {
189
+ isLoggedIn = true;
190
+ console.log(`✅ Logged in as ${creds.email || 'unknown'}`);
191
+ }
192
+ } catch { /* not logged in */ }
193
+
194
+ if (!isLoggedIn) {
195
+ console.log('You need to log in to Sloss first.\n');
196
+ const baseUrl = options.url || 'https://sloss.ngrok.app';
197
+ await loginCommand({}, baseUrl);
198
+ console.log('');
199
+ }
200
+
201
+ // Step 2: Detect project
202
+ console.log('🔍 Detecting project...');
203
+ const detected = detectProject(projectDir);
204
+
205
+ if (detected.type) {
206
+ console.log(` Found: ${detected.type} project`);
207
+ } else {
208
+ console.log(' Could not auto-detect project type.');
209
+ }
210
+
211
+ // Step 3: Prompt for app name and bundle ID
212
+ console.log('');
213
+ const appName = await prompt('App name', detected.appName || '');
214
+ const bundleId = await prompt('Bundle ID', detected.bundleId || '');
215
+
216
+ if (!appName || !bundleId) {
217
+ console.error('\n❌ App name and bundle ID are required.');
218
+ process.exit(1);
219
+ }
220
+
221
+ // Step 4: Build .sloss.json with conventions
222
+ const profiles = detectProfiles(projectDir) || {
223
+ development: { bundle_id_suffix: '.dev' },
224
+ preview: { bundle_id_suffix: '.preview' },
225
+ production: { bundle_id_suffix: '' },
226
+ };
227
+
228
+ const config = {
229
+ type: detected.type || 'expo',
230
+ app_name: appName,
231
+ bundle_id: bundleId,
232
+ version_file: 'app.version.json',
233
+ build_dir: '.',
234
+ ios_plist: 'ios/switchboard/Info.plist',
235
+ profiles,
236
+ };
237
+
238
+ // Try to find the actual iOS plist path
239
+ const plistCandidates = [
240
+ `ios/${appName.toLowerCase()}/Info.plist`,
241
+ `ios/${appName.replace(/\s+/g, '')}/Info.plist`,
242
+ `ios/${appName}/Info.plist`,
243
+ ];
244
+ for (const candidate of plistCandidates) {
245
+ if (existsSync(join(projectDir, candidate))) {
246
+ config.ios_plist = candidate;
247
+ break;
248
+ }
249
+ }
250
+
251
+ // If no ios_plist found, try a glob
252
+ if (!existsSync(join(projectDir, config.ios_plist))) {
253
+ try {
254
+ const { readdirSync } = await import('fs');
255
+ const iosDir = join(projectDir, 'ios');
256
+ if (existsSync(iosDir)) {
257
+ const dirs = readdirSync(iosDir, { withFileTypes: true })
258
+ .filter(d => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'Pods' && d.name !== 'build');
259
+ for (const dir of dirs) {
260
+ const plist = join('ios', dir.name, 'Info.plist');
261
+ if (existsSync(join(projectDir, plist))) {
262
+ config.ios_plist = plist;
263
+ break;
264
+ }
265
+ }
266
+ }
267
+ } catch { /* ignore */ }
268
+ }
269
+
270
+ // Ensure version file exists
271
+ const versionFilePath = join(projectDir, 'app.version.json');
272
+ if (!existsSync(versionFilePath)) {
273
+ const createVersionFile = await promptYesNo('app.version.json not found. Create it?', true);
274
+ if (createVersionFile) {
275
+ writeFileSync(versionFilePath, JSON.stringify({ version: '1.0.0', buildNumber: '1' }, null, 2) + '\n');
276
+ console.log(' Created app.version.json');
277
+ }
278
+ }
279
+
280
+ // Write .sloss.json
281
+ writeFileSync(slossPath, JSON.stringify(config, null, 2) + '\n');
282
+
283
+ console.log('');
284
+ console.log('✅ Created .sloss.json');
285
+ console.log('');
286
+ console.log(' Next steps:');
287
+ console.log(' 1. Commit .sloss.json to your repo');
288
+ console.log(' 2. Run `sloss build` to queue your first build');
289
+ console.log('');
290
+ }