usemint-cli 0.2.0-beta.4 → 0.2.0-beta.5
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/dist/cli/index.js +1094 -157
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -110,6 +110,174 @@ var init_config = __esm({
|
|
|
110
110
|
}
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
// src/cli/commands/auth.ts
|
|
114
|
+
var auth_exports = {};
|
|
115
|
+
__export(auth_exports, {
|
|
116
|
+
login: () => login,
|
|
117
|
+
logout: () => logout,
|
|
118
|
+
signup: () => signup,
|
|
119
|
+
whoami: () => whoami
|
|
120
|
+
});
|
|
121
|
+
import chalk from "chalk";
|
|
122
|
+
import boxen from "boxen";
|
|
123
|
+
import { createServer } from "http";
|
|
124
|
+
async function login() {
|
|
125
|
+
if (config2.isAuthenticated()) {
|
|
126
|
+
const email = config2.get("email");
|
|
127
|
+
console.log(chalk.yellow(`
|
|
128
|
+
Already logged in as ${email}`));
|
|
129
|
+
console.log(chalk.dim(" Run `mint logout` to switch accounts.\n"));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
console.log(chalk.cyan("\n Opening browser to sign in...\n"));
|
|
133
|
+
const token = await waitForOAuthCallback();
|
|
134
|
+
if (!token) {
|
|
135
|
+
console.log(chalk.red("\n Login failed. Try again with `mint login`.\n"));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
|
140
|
+
headers: {
|
|
141
|
+
"apikey": SUPABASE_ANON_KEY,
|
|
142
|
+
"Authorization": `Bearer ${token}`
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
console.log(chalk.red("\n Invalid token received. Try again.\n"));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const user = await res.json();
|
|
150
|
+
config2.setAll({
|
|
151
|
+
apiKey: token,
|
|
152
|
+
userId: user.id,
|
|
153
|
+
email: user.email
|
|
154
|
+
});
|
|
155
|
+
console.log(boxen(
|
|
156
|
+
`${chalk.bold.green("Signed in!")}
|
|
157
|
+
|
|
158
|
+
Email: ${chalk.cyan(user.email)}
|
|
159
|
+
Plan: ${chalk.dim("Free \u2014 20 tasks/day")}
|
|
160
|
+
|
|
161
|
+
${chalk.dim("Run `mint` to start coding.")}`,
|
|
162
|
+
{ padding: 1, borderColor: "green", borderStyle: "round" }
|
|
163
|
+
));
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.log(chalk.red(`
|
|
166
|
+
Error: ${err.message}
|
|
167
|
+
`));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function waitForOAuthCallback() {
|
|
171
|
+
return new Promise((resolve12) => {
|
|
172
|
+
const timeout = setTimeout(() => {
|
|
173
|
+
server.close();
|
|
174
|
+
resolve12(null);
|
|
175
|
+
}, 12e4);
|
|
176
|
+
const server = createServer(async (req, res) => {
|
|
177
|
+
const url = new URL(req.url ?? "/", `http://localhost:${CALLBACK_PORT}`);
|
|
178
|
+
if (url.pathname === "/callback") {
|
|
179
|
+
let token = null;
|
|
180
|
+
if (req.method === "POST") {
|
|
181
|
+
const body = await new Promise((r) => {
|
|
182
|
+
let data = "";
|
|
183
|
+
req.on("data", (chunk) => {
|
|
184
|
+
data += chunk.toString();
|
|
185
|
+
});
|
|
186
|
+
req.on("end", () => r(data));
|
|
187
|
+
});
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(body);
|
|
190
|
+
token = parsed.access_token ?? parsed.token ?? null;
|
|
191
|
+
} catch {
|
|
192
|
+
token = null;
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
token = url.searchParams.get("access_token") ?? url.searchParams.get("token");
|
|
196
|
+
}
|
|
197
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
198
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
199
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
200
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
201
|
+
res.end(`
|
|
202
|
+
<html>
|
|
203
|
+
<body style="background:#07090d;color:#c8dae8;font-family:monospace;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
|
|
204
|
+
<div style="text-align:center">
|
|
205
|
+
<h1 style="color:#00d4ff">Connected!</h1>
|
|
206
|
+
<p>You can close this tab and return to the terminal.</p>
|
|
207
|
+
</div>
|
|
208
|
+
</body>
|
|
209
|
+
</html>
|
|
210
|
+
`);
|
|
211
|
+
clearTimeout(timeout);
|
|
212
|
+
server.close();
|
|
213
|
+
resolve12(token);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (req.method === "OPTIONS") {
|
|
217
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
218
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
219
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
220
|
+
res.writeHead(204);
|
|
221
|
+
res.end();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
res.writeHead(404);
|
|
225
|
+
res.end("Not found");
|
|
226
|
+
});
|
|
227
|
+
server.listen(CALLBACK_PORT, () => {
|
|
228
|
+
const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
|
|
229
|
+
const authUrl = `${AUTH_PAGE_URL}?callback=${encodeURIComponent(callbackUrl)}`;
|
|
230
|
+
import("child_process").then(({ execFile }) => {
|
|
231
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
232
|
+
execFile(cmd, [authUrl]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
server.on("error", () => {
|
|
236
|
+
clearTimeout(timeout);
|
|
237
|
+
resolve12(null);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async function signup() {
|
|
242
|
+
await login();
|
|
243
|
+
}
|
|
244
|
+
async function logout() {
|
|
245
|
+
if (!config2.isAuthenticated()) {
|
|
246
|
+
console.log(chalk.yellow("\n Not logged in.\n"));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const email = config2.get("email");
|
|
250
|
+
config2.clear();
|
|
251
|
+
console.log(chalk.green(`
|
|
252
|
+
Logged out from ${email}
|
|
253
|
+
`));
|
|
254
|
+
}
|
|
255
|
+
async function whoami() {
|
|
256
|
+
if (!config2.isAuthenticated()) {
|
|
257
|
+
console.log(chalk.yellow("\n Not logged in."));
|
|
258
|
+
console.log(chalk.dim(" Run `mint login` to sign in.\n"));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const email = config2.get("email");
|
|
262
|
+
console.log(boxen(
|
|
263
|
+
`${chalk.bold("Signed in")}
|
|
264
|
+
|
|
265
|
+
Email: ${chalk.cyan(email)}`,
|
|
266
|
+
{ padding: 1, borderColor: "cyan", borderStyle: "round" }
|
|
267
|
+
));
|
|
268
|
+
}
|
|
269
|
+
var SUPABASE_URL, SUPABASE_ANON_KEY, AUTH_PAGE_URL, CALLBACK_PORT;
|
|
270
|
+
var init_auth = __esm({
|
|
271
|
+
"src/cli/commands/auth.ts"() {
|
|
272
|
+
"use strict";
|
|
273
|
+
init_config();
|
|
274
|
+
SUPABASE_URL = process.env.MINT_SUPABASE_URL ?? "https://srhoryezzsjmjdgfoxgd.supabase.co";
|
|
275
|
+
SUPABASE_ANON_KEY = process.env.MINT_SUPABASE_ANON_KEY ?? "";
|
|
276
|
+
AUTH_PAGE_URL = "https://usemint.dev/auth";
|
|
277
|
+
CALLBACK_PORT = 9876;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
113
281
|
// src/providers/types.ts
|
|
114
282
|
var types_exports = {};
|
|
115
283
|
__export(types_exports, {
|
|
@@ -3409,7 +3577,550 @@ describe("projectService.create", () => {
|
|
|
3409
3577
|
- [ ] Assertions are specific (not just "toBeTruthy")
|
|
3410
3578
|
`;
|
|
3411
3579
|
}
|
|
3412
|
-
|
|
3580
|
+
function SKILL_ANDROID(hasCompose) {
|
|
3581
|
+
const uiSection = hasCompose ? ANDROID_COMPOSE_SECTION : ANDROID_XML_SECTION;
|
|
3582
|
+
return `---
|
|
3583
|
+
applies_to: [android, mobile]
|
|
3584
|
+
---
|
|
3585
|
+
# Android / Kotlin \u2014 Production Quality Standard
|
|
3586
|
+
|
|
3587
|
+
All generated code must match Google's best Android apps (Gmail, Drive, Maps).
|
|
3588
|
+
Study these reference examples \u2014 this is the bar.
|
|
3589
|
+
|
|
3590
|
+
## Rules
|
|
3591
|
+
- Kotlin only \u2014 no Java in new code
|
|
3592
|
+
- MVVM with clean architecture: UI \u2192 ViewModel \u2192 UseCase \u2192 Repository \u2192 DataSource
|
|
3593
|
+
- Coroutines + Flow for all async work \u2014 no callbacks, no RxJava in new code
|
|
3594
|
+
- Hilt for dependency injection \u2014 no manual DI or service locators
|
|
3595
|
+
- Single Activity with Jetpack Navigation (or Compose Navigation)
|
|
3596
|
+
- Repository pattern: ViewModel never touches Room/Retrofit directly
|
|
3597
|
+
- Sealed classes for UI state \u2014 never nullable booleans for loading/error
|
|
3598
|
+
|
|
3599
|
+
${uiSection}
|
|
3600
|
+
|
|
3601
|
+
## Reference: ViewModel (Google-quality MVVM)
|
|
3602
|
+
|
|
3603
|
+
\`\`\`kotlin
|
|
3604
|
+
@HiltViewModel
|
|
3605
|
+
class ProjectListViewModel @Inject constructor(
|
|
3606
|
+
private val getProjects: GetProjectsUseCase,
|
|
3607
|
+
private val deleteProject: DeleteProjectUseCase,
|
|
3608
|
+
) : ViewModel() {
|
|
3609
|
+
|
|
3610
|
+
private val _uiState = MutableStateFlow<ProjectListState>(ProjectListState.Loading)
|
|
3611
|
+
val uiState: StateFlow<ProjectListState> = _uiState.asStateFlow()
|
|
3612
|
+
|
|
3613
|
+
private val _events = Channel<ProjectListEvent>(Channel.BUFFERED)
|
|
3614
|
+
val events: Flow<ProjectListEvent> = _events.receiveAsFlow()
|
|
3615
|
+
|
|
3616
|
+
init {
|
|
3617
|
+
loadProjects()
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
fun loadProjects() {
|
|
3621
|
+
viewModelScope.launch {
|
|
3622
|
+
_uiState.value = ProjectListState.Loading
|
|
3623
|
+
getProjects()
|
|
3624
|
+
.catch { e -> _uiState.value = ProjectListState.Error(e.toUserMessage()) }
|
|
3625
|
+
.collect { projects ->
|
|
3626
|
+
_uiState.value = if (projects.isEmpty()) {
|
|
3627
|
+
ProjectListState.Empty
|
|
3628
|
+
} else {
|
|
3629
|
+
ProjectListState.Success(projects)
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
fun onDeleteProject(projectId: String) {
|
|
3636
|
+
viewModelScope.launch {
|
|
3637
|
+
try {
|
|
3638
|
+
deleteProject(projectId)
|
|
3639
|
+
_events.send(ProjectListEvent.ProjectDeleted)
|
|
3640
|
+
loadProjects()
|
|
3641
|
+
} catch (e: Exception) {
|
|
3642
|
+
_events.send(ProjectListEvent.ShowError(e.toUserMessage()))
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
sealed interface ProjectListState {
|
|
3649
|
+
data object Loading : ProjectListState
|
|
3650
|
+
data object Empty : ProjectListState
|
|
3651
|
+
data class Success(val projects: List<Project>) : ProjectListState
|
|
3652
|
+
data class Error(val message: String) : ProjectListState
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
sealed interface ProjectListEvent {
|
|
3656
|
+
data object ProjectDeleted : ProjectListEvent
|
|
3657
|
+
data class ShowError(val message: String) : ProjectListEvent
|
|
3658
|
+
}
|
|
3659
|
+
\`\`\`
|
|
3660
|
+
|
|
3661
|
+
## Reference: Repository with offline-first caching
|
|
3662
|
+
|
|
3663
|
+
\`\`\`kotlin
|
|
3664
|
+
class ProjectRepositoryImpl @Inject constructor(
|
|
3665
|
+
private val api: ProjectApi,
|
|
3666
|
+
private val dao: ProjectDao,
|
|
3667
|
+
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
|
3668
|
+
) : ProjectRepository {
|
|
3669
|
+
|
|
3670
|
+
override fun getProjects(): Flow<List<Project>> = flow {
|
|
3671
|
+
// Emit cached data immediately
|
|
3672
|
+
val cached = dao.getAll().first()
|
|
3673
|
+
if (cached.isNotEmpty()) {
|
|
3674
|
+
emit(cached.map { it.toDomain() })
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
// Fetch fresh data from network
|
|
3678
|
+
try {
|
|
3679
|
+
val remote = withContext(dispatcher) { api.getProjects() }
|
|
3680
|
+
dao.replaceAll(remote.map { it.toEntity() })
|
|
3681
|
+
} catch (e: IOException) {
|
|
3682
|
+
if (cached.isEmpty()) throw e
|
|
3683
|
+
// Cached data already emitted \u2014 silently use stale data
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
// Emit fresh data from DB (single source of truth)
|
|
3687
|
+
emitAll(dao.getAll().map { entities -> entities.map { it.toDomain() } })
|
|
3688
|
+
}.flowOn(dispatcher)
|
|
3689
|
+
|
|
3690
|
+
override suspend fun delete(projectId: String) {
|
|
3691
|
+
withContext(dispatcher) {
|
|
3692
|
+
api.deleteProject(projectId)
|
|
3693
|
+
dao.deleteById(projectId)
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
\`\`\`
|
|
3698
|
+
|
|
3699
|
+
## Reference: UseCase (clean architecture boundary)
|
|
3700
|
+
|
|
3701
|
+
\`\`\`kotlin
|
|
3702
|
+
class GetProjectsUseCase @Inject constructor(
|
|
3703
|
+
private val repository: ProjectRepository,
|
|
3704
|
+
) {
|
|
3705
|
+
operator fun invoke(): Flow<List<Project>> = repository.getProjects()
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
class DeleteProjectUseCase @Inject constructor(
|
|
3709
|
+
private val repository: ProjectRepository,
|
|
3710
|
+
private val analytics: AnalyticsTracker,
|
|
3711
|
+
) {
|
|
3712
|
+
suspend operator fun invoke(projectId: String) {
|
|
3713
|
+
repository.delete(projectId)
|
|
3714
|
+
analytics.track("project_deleted", mapOf("id" to projectId))
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
\`\`\`
|
|
3718
|
+
|
|
3719
|
+
## Reference: Hilt module
|
|
3720
|
+
|
|
3721
|
+
\`\`\`kotlin
|
|
3722
|
+
@Module
|
|
3723
|
+
@InstallIn(SingletonComponent::class)
|
|
3724
|
+
abstract class RepositoryModule {
|
|
3725
|
+
@Binds
|
|
3726
|
+
abstract fun bindProjectRepository(impl: ProjectRepositoryImpl): ProjectRepository
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
@Module
|
|
3730
|
+
@InstallIn(SingletonComponent::class)
|
|
3731
|
+
object NetworkModule {
|
|
3732
|
+
@Provides
|
|
3733
|
+
@Singleton
|
|
3734
|
+
fun provideRetrofit(): Retrofit = Retrofit.Builder()
|
|
3735
|
+
.baseUrl(BuildConfig.API_BASE_URL)
|
|
3736
|
+
.addConverterFactory(MoshiConverterFactory.create())
|
|
3737
|
+
.client(
|
|
3738
|
+
OkHttpClient.Builder()
|
|
3739
|
+
.addInterceptor(AuthInterceptor())
|
|
3740
|
+
.addInterceptor(HttpLoggingInterceptor().apply {
|
|
3741
|
+
level = if (BuildConfig.DEBUG) Level.BODY else Level.NONE
|
|
3742
|
+
})
|
|
3743
|
+
.connectTimeout(15, TimeUnit.SECONDS)
|
|
3744
|
+
.readTimeout(15, TimeUnit.SECONDS)
|
|
3745
|
+
.build()
|
|
3746
|
+
)
|
|
3747
|
+
.build()
|
|
3748
|
+
|
|
3749
|
+
@Provides
|
|
3750
|
+
@Singleton
|
|
3751
|
+
fun provideProjectApi(retrofit: Retrofit): ProjectApi =
|
|
3752
|
+
retrofit.create(ProjectApi::class.java)
|
|
3753
|
+
}
|
|
3754
|
+
\`\`\`
|
|
3755
|
+
|
|
3756
|
+
## Reference: Room entity + DAO
|
|
3757
|
+
|
|
3758
|
+
\`\`\`kotlin
|
|
3759
|
+
@Entity(tableName = "projects")
|
|
3760
|
+
data class ProjectEntity(
|
|
3761
|
+
@PrimaryKey val id: String,
|
|
3762
|
+
val name: String,
|
|
3763
|
+
val description: String?,
|
|
3764
|
+
@ColumnInfo(name = "team_id") val teamId: String,
|
|
3765
|
+
@ColumnInfo(name = "created_at") val createdAt: Long,
|
|
3766
|
+
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
|
3767
|
+
) {
|
|
3768
|
+
fun toDomain() = Project(
|
|
3769
|
+
id = id,
|
|
3770
|
+
name = name,
|
|
3771
|
+
description = description,
|
|
3772
|
+
teamId = teamId,
|
|
3773
|
+
createdAt = Instant.ofEpochMilli(createdAt),
|
|
3774
|
+
)
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
@Dao
|
|
3778
|
+
interface ProjectDao {
|
|
3779
|
+
@Query("SELECT * FROM projects ORDER BY updated_at DESC")
|
|
3780
|
+
fun getAll(): Flow<List<ProjectEntity>>
|
|
3781
|
+
|
|
3782
|
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
3783
|
+
suspend fun insertAll(projects: List<ProjectEntity>)
|
|
3784
|
+
|
|
3785
|
+
@Query("DELETE FROM projects")
|
|
3786
|
+
suspend fun deleteAll()
|
|
3787
|
+
|
|
3788
|
+
@Transaction
|
|
3789
|
+
suspend fun replaceAll(projects: List<ProjectEntity>) {
|
|
3790
|
+
deleteAll()
|
|
3791
|
+
insertAll(projects)
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
@Query("DELETE FROM projects WHERE id = :id")
|
|
3795
|
+
suspend fun deleteById(id: String)
|
|
3796
|
+
}
|
|
3797
|
+
\`\`\`
|
|
3798
|
+
|
|
3799
|
+
## Quality checklist (reviewer must verify)
|
|
3800
|
+
- [ ] UI state is a sealed class/interface \u2014 no nullable boolean flags
|
|
3801
|
+
- [ ] ViewModel uses StateFlow (not LiveData) for new code
|
|
3802
|
+
- [ ] Coroutine scope is viewModelScope \u2014 never GlobalScope
|
|
3803
|
+
- [ ] Repository is the single source of truth (DB, not network)
|
|
3804
|
+
- [ ] Hilt @Inject on every constructor \u2014 no manual instantiation
|
|
3805
|
+
- [ ] Error messages are user-friendly \u2014 never raw exception text
|
|
3806
|
+
- [ ] Lifecycle-aware collection (collectAsStateWithLifecycle or repeatOnLifecycle)
|
|
3807
|
+
- [ ] No hardcoded strings in UI \u2014 use string resources
|
|
3808
|
+
- [ ] ProGuard/R8 rules for any reflection-based libraries
|
|
3809
|
+
`;
|
|
3810
|
+
}
|
|
3811
|
+
function SKILL_DEVOPS(hasDocker, hasCI, hasTerraform, hasK8s) {
|
|
3812
|
+
const sections = [];
|
|
3813
|
+
sections.push(`---
|
|
3814
|
+
applies_to: [devops, infrastructure]
|
|
3815
|
+
---
|
|
3816
|
+
# DevOps \u2014 Production Quality Standard
|
|
3817
|
+
|
|
3818
|
+
Infrastructure as code, reproducible builds, zero-downtime deploys.`);
|
|
3819
|
+
sections.push(`
|
|
3820
|
+
## Rules
|
|
3821
|
+
- Every environment is reproducible from code \u2014 no manual server config
|
|
3822
|
+
- Secrets in vault/env \u2014 never in code, never in Docker images, never in CI logs
|
|
3823
|
+
- Health checks on every service \u2014 liveness and readiness
|
|
3824
|
+
- Logs are structured JSON \u2014 never print() or console.log for production logging
|
|
3825
|
+
- Rollback plan exists for every deploy
|
|
3826
|
+
- Least privilege: containers run as non-root, IAM roles are scoped`);
|
|
3827
|
+
if (hasDocker) {
|
|
3828
|
+
sections.push(`
|
|
3829
|
+
## Reference: Production Dockerfile (multi-stage, secure)
|
|
3830
|
+
|
|
3831
|
+
\`\`\`dockerfile
|
|
3832
|
+
# Build stage
|
|
3833
|
+
FROM node:20-alpine AS builder
|
|
3834
|
+
WORKDIR /app
|
|
3835
|
+
COPY package.json package-lock.json ./
|
|
3836
|
+
RUN npm ci --ignore-scripts
|
|
3837
|
+
COPY . .
|
|
3838
|
+
RUN npm run build && npm prune --production
|
|
3839
|
+
|
|
3840
|
+
# Production stage
|
|
3841
|
+
FROM node:20-alpine AS runner
|
|
3842
|
+
WORKDIR /app
|
|
3843
|
+
|
|
3844
|
+
# Security: non-root user
|
|
3845
|
+
RUN addgroup --system --gid 1001 appgroup && \\
|
|
3846
|
+
adduser --system --uid 1001 appuser
|
|
3847
|
+
|
|
3848
|
+
# Only copy what's needed
|
|
3849
|
+
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
|
|
3850
|
+
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
|
|
3851
|
+
COPY --from=builder --chown=appuser:appgroup /app/package.json ./
|
|
3852
|
+
|
|
3853
|
+
# Health check
|
|
3854
|
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
|
|
3855
|
+
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
|
3856
|
+
|
|
3857
|
+
USER appuser
|
|
3858
|
+
EXPOSE 3000
|
|
3859
|
+
ENV NODE_ENV=production
|
|
3860
|
+
|
|
3861
|
+
CMD ["node", "dist/server.js"]
|
|
3862
|
+
\`\`\`
|
|
3863
|
+
|
|
3864
|
+
## Reference: docker-compose for local dev
|
|
3865
|
+
|
|
3866
|
+
\`\`\`yaml
|
|
3867
|
+
services:
|
|
3868
|
+
app:
|
|
3869
|
+
build:
|
|
3870
|
+
context: .
|
|
3871
|
+
target: builder # Use build stage for dev (has devDependencies)
|
|
3872
|
+
ports:
|
|
3873
|
+
- "3000:3000"
|
|
3874
|
+
volumes:
|
|
3875
|
+
- .:/app
|
|
3876
|
+
- /app/node_modules # Don't mount over node_modules
|
|
3877
|
+
environment:
|
|
3878
|
+
- DATABASE_URL=postgresql://postgres:postgres@db:5432/app_dev
|
|
3879
|
+
- REDIS_URL=redis://redis:6379
|
|
3880
|
+
- NODE_ENV=development
|
|
3881
|
+
depends_on:
|
|
3882
|
+
db:
|
|
3883
|
+
condition: service_healthy
|
|
3884
|
+
redis:
|
|
3885
|
+
condition: service_started
|
|
3886
|
+
|
|
3887
|
+
db:
|
|
3888
|
+
image: postgres:16-alpine
|
|
3889
|
+
environment:
|
|
3890
|
+
POSTGRES_DB: app_dev
|
|
3891
|
+
POSTGRES_USER: postgres
|
|
3892
|
+
POSTGRES_PASSWORD: postgres
|
|
3893
|
+
ports:
|
|
3894
|
+
- "5432:5432"
|
|
3895
|
+
volumes:
|
|
3896
|
+
- pgdata:/var/lib/postgresql/data
|
|
3897
|
+
healthcheck:
|
|
3898
|
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
3899
|
+
interval: 5s
|
|
3900
|
+
timeout: 3s
|
|
3901
|
+
retries: 5
|
|
3902
|
+
|
|
3903
|
+
redis:
|
|
3904
|
+
image: redis:7-alpine
|
|
3905
|
+
ports:
|
|
3906
|
+
- "6379:6379"
|
|
3907
|
+
|
|
3908
|
+
volumes:
|
|
3909
|
+
pgdata:
|
|
3910
|
+
\`\`\``);
|
|
3911
|
+
}
|
|
3912
|
+
if (hasCI) {
|
|
3913
|
+
sections.push(`
|
|
3914
|
+
## Reference: GitHub Actions CI/CD pipeline
|
|
3915
|
+
|
|
3916
|
+
\`\`\`yaml
|
|
3917
|
+
name: CI/CD
|
|
3918
|
+
|
|
3919
|
+
on:
|
|
3920
|
+
push:
|
|
3921
|
+
branches: [main]
|
|
3922
|
+
pull_request:
|
|
3923
|
+
branches: [main]
|
|
3924
|
+
|
|
3925
|
+
concurrency:
|
|
3926
|
+
group: \${{ github.workflow }}-\${{ github.ref }}
|
|
3927
|
+
cancel-in-progress: true
|
|
3928
|
+
|
|
3929
|
+
jobs:
|
|
3930
|
+
test:
|
|
3931
|
+
runs-on: ubuntu-latest
|
|
3932
|
+
services:
|
|
3933
|
+
postgres:
|
|
3934
|
+
image: postgres:16-alpine
|
|
3935
|
+
env:
|
|
3936
|
+
POSTGRES_DB: test
|
|
3937
|
+
POSTGRES_USER: postgres
|
|
3938
|
+
POSTGRES_PASSWORD: postgres
|
|
3939
|
+
ports:
|
|
3940
|
+
- 5432:5432
|
|
3941
|
+
options: >-
|
|
3942
|
+
--health-cmd "pg_isready"
|
|
3943
|
+
--health-interval 10s
|
|
3944
|
+
--health-timeout 5s
|
|
3945
|
+
--health-retries 5
|
|
3946
|
+
steps:
|
|
3947
|
+
- uses: actions/checkout@v4
|
|
3948
|
+
- uses: actions/setup-node@v4
|
|
3949
|
+
with:
|
|
3950
|
+
node-version: 20
|
|
3951
|
+
cache: npm
|
|
3952
|
+
- run: npm ci
|
|
3953
|
+
- run: npm run typecheck
|
|
3954
|
+
- run: npm run lint
|
|
3955
|
+
- run: npm test
|
|
3956
|
+
env:
|
|
3957
|
+
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
|
|
3958
|
+
|
|
3959
|
+
build:
|
|
3960
|
+
needs: test
|
|
3961
|
+
runs-on: ubuntu-latest
|
|
3962
|
+
if: github.ref == 'refs/heads/main'
|
|
3963
|
+
permissions:
|
|
3964
|
+
contents: read
|
|
3965
|
+
packages: write
|
|
3966
|
+
steps:
|
|
3967
|
+
- uses: actions/checkout@v4
|
|
3968
|
+
- uses: docker/setup-buildx-action@v3
|
|
3969
|
+
- uses: docker/login-action@v3
|
|
3970
|
+
with:
|
|
3971
|
+
registry: ghcr.io
|
|
3972
|
+
username: \${{ github.actor }}
|
|
3973
|
+
password: \${{ secrets.GITHUB_TOKEN }}
|
|
3974
|
+
- uses: docker/build-push-action@v5
|
|
3975
|
+
with:
|
|
3976
|
+
push: true
|
|
3977
|
+
tags: ghcr.io/\${{ github.repository }}:\${{ github.sha }}
|
|
3978
|
+
cache-from: type=gha
|
|
3979
|
+
cache-to: type=gha,mode=max
|
|
3980
|
+
|
|
3981
|
+
deploy:
|
|
3982
|
+
needs: build
|
|
3983
|
+
runs-on: ubuntu-latest
|
|
3984
|
+
if: github.ref == 'refs/heads/main'
|
|
3985
|
+
environment: production
|
|
3986
|
+
steps:
|
|
3987
|
+
- name: Deploy to production
|
|
3988
|
+
run: |
|
|
3989
|
+
# Replace with your deploy command
|
|
3990
|
+
echo "Deploying ghcr.io/\${{ github.repository }}:\${{ github.sha }}"
|
|
3991
|
+
\`\`\``);
|
|
3992
|
+
}
|
|
3993
|
+
if (hasTerraform) {
|
|
3994
|
+
sections.push(`
|
|
3995
|
+
## Reference: Terraform module structure
|
|
3996
|
+
|
|
3997
|
+
\`\`\`hcl
|
|
3998
|
+
# main.tf
|
|
3999
|
+
terraform {
|
|
4000
|
+
required_version = ">= 1.5"
|
|
4001
|
+
required_providers {
|
|
4002
|
+
aws = { source = "hashicorp/aws", version = "~> 5.0" }
|
|
4003
|
+
}
|
|
4004
|
+
backend "s3" {
|
|
4005
|
+
bucket = "myapp-terraform-state"
|
|
4006
|
+
key = "prod/terraform.tfstate"
|
|
4007
|
+
region = "us-east-1"
|
|
4008
|
+
dynamodb_table = "terraform-locks"
|
|
4009
|
+
encrypt = true
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
resource "aws_ecs_service" "app" {
|
|
4014
|
+
name = "\${var.app_name}-\${var.environment}"
|
|
4015
|
+
cluster = aws_ecs_cluster.main.id
|
|
4016
|
+
task_definition = aws_ecs_task_definition.app.arn
|
|
4017
|
+
desired_count = var.min_capacity
|
|
4018
|
+
launch_type = "FARGATE"
|
|
4019
|
+
|
|
4020
|
+
network_configuration {
|
|
4021
|
+
subnets = var.private_subnet_ids
|
|
4022
|
+
security_groups = [aws_security_group.app.id]
|
|
4023
|
+
assign_public_ip = false
|
|
4024
|
+
}
|
|
4025
|
+
|
|
4026
|
+
load_balancer {
|
|
4027
|
+
target_group_arn = aws_lb_target_group.app.arn
|
|
4028
|
+
container_name = var.app_name
|
|
4029
|
+
container_port = var.container_port
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
deployment_circuit_breaker {
|
|
4033
|
+
enable = true
|
|
4034
|
+
rollback = true
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
lifecycle {
|
|
4038
|
+
ignore_changes = [desired_count] # Managed by auto-scaling
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
\`\`\``);
|
|
4042
|
+
}
|
|
4043
|
+
if (hasK8s) {
|
|
4044
|
+
sections.push(`
|
|
4045
|
+
## Reference: Kubernetes deployment with best practices
|
|
4046
|
+
|
|
4047
|
+
\`\`\`yaml
|
|
4048
|
+
apiVersion: apps/v1
|
|
4049
|
+
kind: Deployment
|
|
4050
|
+
metadata:
|
|
4051
|
+
name: app
|
|
4052
|
+
labels:
|
|
4053
|
+
app.kubernetes.io/name: app
|
|
4054
|
+
app.kubernetes.io/version: "1.0.0"
|
|
4055
|
+
spec:
|
|
4056
|
+
replicas: 3
|
|
4057
|
+
strategy:
|
|
4058
|
+
type: RollingUpdate
|
|
4059
|
+
rollingUpdate:
|
|
4060
|
+
maxSurge: 1
|
|
4061
|
+
maxUnavailable: 0
|
|
4062
|
+
selector:
|
|
4063
|
+
matchLabels:
|
|
4064
|
+
app.kubernetes.io/name: app
|
|
4065
|
+
template:
|
|
4066
|
+
metadata:
|
|
4067
|
+
labels:
|
|
4068
|
+
app.kubernetes.io/name: app
|
|
4069
|
+
spec:
|
|
4070
|
+
securityContext:
|
|
4071
|
+
runAsNonRoot: true
|
|
4072
|
+
runAsUser: 1001
|
|
4073
|
+
fsGroup: 1001
|
|
4074
|
+
containers:
|
|
4075
|
+
- name: app
|
|
4076
|
+
image: ghcr.io/org/app:latest
|
|
4077
|
+
ports:
|
|
4078
|
+
- containerPort: 3000
|
|
4079
|
+
protocol: TCP
|
|
4080
|
+
env:
|
|
4081
|
+
- name: DATABASE_URL
|
|
4082
|
+
valueFrom:
|
|
4083
|
+
secretKeyRef:
|
|
4084
|
+
name: app-secrets
|
|
4085
|
+
key: database-url
|
|
4086
|
+
resources:
|
|
4087
|
+
requests:
|
|
4088
|
+
cpu: 100m
|
|
4089
|
+
memory: 128Mi
|
|
4090
|
+
limits:
|
|
4091
|
+
cpu: 500m
|
|
4092
|
+
memory: 512Mi
|
|
4093
|
+
livenessProbe:
|
|
4094
|
+
httpGet:
|
|
4095
|
+
path: /health
|
|
4096
|
+
port: 3000
|
|
4097
|
+
initialDelaySeconds: 10
|
|
4098
|
+
periodSeconds: 30
|
|
4099
|
+
readinessProbe:
|
|
4100
|
+
httpGet:
|
|
4101
|
+
path: /health
|
|
4102
|
+
port: 3000
|
|
4103
|
+
initialDelaySeconds: 5
|
|
4104
|
+
periodSeconds: 10
|
|
4105
|
+
securityContext:
|
|
4106
|
+
allowPrivilegeEscalation: false
|
|
4107
|
+
readOnlyRootFilesystem: true
|
|
4108
|
+
\`\`\``);
|
|
4109
|
+
}
|
|
4110
|
+
sections.push(`
|
|
4111
|
+
## Quality checklist (reviewer must verify)
|
|
4112
|
+
- [ ] No secrets in code, Dockerfiles, or CI logs \u2014 use env vars or secret stores
|
|
4113
|
+
- [ ] Containers run as non-root with read-only filesystem where possible
|
|
4114
|
+
- [ ] Health checks on every service (liveness + readiness)
|
|
4115
|
+
- [ ] CI runs typecheck + lint + test before deploy
|
|
4116
|
+
- [ ] Deploys are zero-downtime (rolling update, not recreate)
|
|
4117
|
+
- [ ] Resource limits set on all containers
|
|
4118
|
+
- [ ] State is external (DB, Redis, S3) \u2014 containers are stateless
|
|
4119
|
+
- [ ] Rollback is one command or automatic on failure
|
|
4120
|
+
`);
|
|
4121
|
+
return sections.join("\n");
|
|
4122
|
+
}
|
|
4123
|
+
var cache, SKILL_STYLE, SKILL_PYTHON, ANDROID_COMPOSE_SECTION, ANDROID_XML_SECTION, SKILL_FULLSTACK, SKILL_GENERAL;
|
|
3413
4124
|
var init_project_rules = __esm({
|
|
3414
4125
|
"src/context/project-rules.ts"() {
|
|
3415
4126
|
"use strict";
|
|
@@ -3559,6 +4270,349 @@ class ProjectService:
|
|
|
3559
4270
|
member = await self._db.team_members.find_one(user_id=user_id, team_id=team_id)
|
|
3560
4271
|
return member is not None
|
|
3561
4272
|
\`\`\`
|
|
4273
|
+
`;
|
|
4274
|
+
ANDROID_COMPOSE_SECTION = `
|
|
4275
|
+
## Reference: Compose UI (Material3 quality)
|
|
4276
|
+
|
|
4277
|
+
\`\`\`kotlin
|
|
4278
|
+
@Composable
|
|
4279
|
+
fun ProjectListScreen(
|
|
4280
|
+
viewModel: ProjectListViewModel = hiltViewModel(),
|
|
4281
|
+
onNavigateToDetail: (String) -> Unit,
|
|
4282
|
+
) {
|
|
4283
|
+
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
4284
|
+
val snackbarHostState = remember { SnackbarHostState() }
|
|
4285
|
+
|
|
4286
|
+
LaunchedEffect(Unit) {
|
|
4287
|
+
viewModel.events.collect { event ->
|
|
4288
|
+
when (event) {
|
|
4289
|
+
is ProjectListEvent.ProjectDeleted ->
|
|
4290
|
+
snackbarHostState.showSnackbar("Project deleted")
|
|
4291
|
+
is ProjectListEvent.ShowError ->
|
|
4292
|
+
snackbarHostState.showSnackbar(event.message)
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
|
|
4297
|
+
Scaffold(
|
|
4298
|
+
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
4299
|
+
topBar = {
|
|
4300
|
+
TopAppBar(title = { Text("Projects") })
|
|
4301
|
+
},
|
|
4302
|
+
floatingActionButton = {
|
|
4303
|
+
FloatingActionButton(onClick = { /* navigate to create */ }) {
|
|
4304
|
+
Icon(Icons.Default.Add, contentDescription = "Create project")
|
|
4305
|
+
}
|
|
4306
|
+
},
|
|
4307
|
+
) { padding ->
|
|
4308
|
+
when (val state = uiState) {
|
|
4309
|
+
is ProjectListState.Loading -> {
|
|
4310
|
+
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
|
4311
|
+
CircularProgressIndicator()
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
4314
|
+
is ProjectListState.Empty -> {
|
|
4315
|
+
EmptyState(
|
|
4316
|
+
modifier = Modifier.fillMaxSize().padding(padding),
|
|
4317
|
+
icon = Icons.Outlined.Folder,
|
|
4318
|
+
title = "No projects yet",
|
|
4319
|
+
subtitle = "Create your first project to get started",
|
|
4320
|
+
)
|
|
4321
|
+
}
|
|
4322
|
+
is ProjectListState.Error -> {
|
|
4323
|
+
ErrorState(
|
|
4324
|
+
modifier = Modifier.fillMaxSize().padding(padding),
|
|
4325
|
+
message = state.message,
|
|
4326
|
+
onRetry = viewModel::loadProjects,
|
|
4327
|
+
)
|
|
4328
|
+
}
|
|
4329
|
+
is ProjectListState.Success -> {
|
|
4330
|
+
LazyColumn(
|
|
4331
|
+
modifier = Modifier.fillMaxSize().padding(padding),
|
|
4332
|
+
contentPadding = PaddingValues(16.dp),
|
|
4333
|
+
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
4334
|
+
) {
|
|
4335
|
+
items(state.projects, key = { it.id }) { project ->
|
|
4336
|
+
ProjectCard(
|
|
4337
|
+
project = project,
|
|
4338
|
+
onClick = { onNavigateToDetail(project.id) },
|
|
4339
|
+
onDelete = { viewModel.onDeleteProject(project.id) },
|
|
4340
|
+
)
|
|
4341
|
+
}
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
@Composable
|
|
4349
|
+
private fun ProjectCard(
|
|
4350
|
+
project: Project,
|
|
4351
|
+
onClick: () -> Unit,
|
|
4352
|
+
onDelete: () -> Unit,
|
|
4353
|
+
modifier: Modifier = Modifier,
|
|
4354
|
+
) {
|
|
4355
|
+
Card(
|
|
4356
|
+
onClick = onClick,
|
|
4357
|
+
modifier = modifier.fillMaxWidth(),
|
|
4358
|
+
) {
|
|
4359
|
+
Row(
|
|
4360
|
+
modifier = Modifier.padding(16.dp),
|
|
4361
|
+
verticalAlignment = Alignment.CenterVertically,
|
|
4362
|
+
) {
|
|
4363
|
+
Column(modifier = Modifier.weight(1f)) {
|
|
4364
|
+
Text(
|
|
4365
|
+
text = project.name,
|
|
4366
|
+
style = MaterialTheme.typography.titleMedium,
|
|
4367
|
+
maxLines = 1,
|
|
4368
|
+
overflow = TextOverflow.Ellipsis,
|
|
4369
|
+
)
|
|
4370
|
+
Text(
|
|
4371
|
+
text = project.description ?: stringResource(R.string.no_description),
|
|
4372
|
+
style = MaterialTheme.typography.bodySmall,
|
|
4373
|
+
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
4374
|
+
maxLines = 2,
|
|
4375
|
+
overflow = TextOverflow.Ellipsis,
|
|
4376
|
+
)
|
|
4377
|
+
}
|
|
4378
|
+
IconButton(onClick = onDelete) {
|
|
4379
|
+
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete))
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
}
|
|
4384
|
+
\`\`\``;
|
|
4385
|
+
ANDROID_XML_SECTION = `
|
|
4386
|
+
## Reference: Fragment with ViewBinding (XML UI)
|
|
4387
|
+
|
|
4388
|
+
\`\`\`kotlin
|
|
4389
|
+
@AndroidEntryPoint
|
|
4390
|
+
class ProjectListFragment : Fragment(R.layout.fragment_project_list) {
|
|
4391
|
+
|
|
4392
|
+
private val viewModel: ProjectListViewModel by viewModels()
|
|
4393
|
+
private var _binding: FragmentProjectListBinding? = null
|
|
4394
|
+
private val binding get() = _binding!!
|
|
4395
|
+
private lateinit var adapter: ProjectAdapter
|
|
4396
|
+
|
|
4397
|
+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
4398
|
+
super.onViewCreated(view, savedInstanceState)
|
|
4399
|
+
_binding = FragmentProjectListBinding.bind(view)
|
|
4400
|
+
|
|
4401
|
+
adapter = ProjectAdapter(
|
|
4402
|
+
onClick = { project -> findNavController().navigate(
|
|
4403
|
+
ProjectListFragmentDirections.actionToDetail(project.id)
|
|
4404
|
+
)},
|
|
4405
|
+
onDelete = { project -> viewModel.onDeleteProject(project.id) },
|
|
4406
|
+
)
|
|
4407
|
+
binding.recyclerView.adapter = adapter
|
|
4408
|
+
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
|
4409
|
+
|
|
4410
|
+
viewLifecycleOwner.lifecycleScope.launch {
|
|
4411
|
+
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
4412
|
+
launch {
|
|
4413
|
+
viewModel.uiState.collect { state ->
|
|
4414
|
+
binding.progressBar.isVisible = state is ProjectListState.Loading
|
|
4415
|
+
binding.emptyView.isVisible = state is ProjectListState.Empty
|
|
4416
|
+
binding.errorView.isVisible = state is ProjectListState.Error
|
|
4417
|
+
binding.recyclerView.isVisible = state is ProjectListState.Success
|
|
4418
|
+
if (state is ProjectListState.Success) adapter.submitList(state.projects)
|
|
4419
|
+
if (state is ProjectListState.Error) binding.errorMessage.text = state.message
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
launch {
|
|
4423
|
+
viewModel.events.collect { event ->
|
|
4424
|
+
when (event) {
|
|
4425
|
+
is ProjectListEvent.ProjectDeleted ->
|
|
4426
|
+
Snackbar.make(binding.root, "Deleted", Snackbar.LENGTH_SHORT).show()
|
|
4427
|
+
is ProjectListEvent.ShowError ->
|
|
4428
|
+
Snackbar.make(binding.root, event.message, Snackbar.LENGTH_LONG).show()
|
|
4429
|
+
}
|
|
4430
|
+
}
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4435
|
+
binding.fab.setOnClickListener {
|
|
4436
|
+
findNavController().navigate(ProjectListFragmentDirections.actionToCreate())
|
|
4437
|
+
}
|
|
4438
|
+
binding.retryButton.setOnClickListener { viewModel.loadProjects() }
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
override fun onDestroyView() {
|
|
4442
|
+
super.onDestroyView()
|
|
4443
|
+
_binding = null
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
\`\`\``;
|
|
4447
|
+
SKILL_FULLSTACK = `---
|
|
4448
|
+
applies_to: [fullstack]
|
|
4449
|
+
---
|
|
4450
|
+
# Fullstack \u2014 Production Quality Standard
|
|
4451
|
+
|
|
4452
|
+
For projects with both frontend and backend. Patterns from Vercel, Linear, Cal.com.
|
|
4453
|
+
|
|
4454
|
+
## Rules
|
|
4455
|
+
- Shared types between frontend and backend \u2014 define once, import in both
|
|
4456
|
+
- API client is a typed wrapper \u2014 never raw fetch() scattered in components
|
|
4457
|
+
- Environment variables: NEXT_PUBLIC_ for client, plain for server \u2014 never leak server secrets
|
|
4458
|
+
- Validation schemas shared: same Zod schema validates on client AND server
|
|
4459
|
+
- Error boundaries in frontend, structured errors from backend
|
|
4460
|
+
- Optimistic UI updates where latency matters, with rollback on failure
|
|
4461
|
+
|
|
4462
|
+
## Reference: Shared types (single source of truth)
|
|
4463
|
+
|
|
4464
|
+
\`\`\`ts
|
|
4465
|
+
// packages/shared/types.ts (or lib/types.ts)
|
|
4466
|
+
export interface Project {
|
|
4467
|
+
id: string;
|
|
4468
|
+
name: string;
|
|
4469
|
+
description: string | null;
|
|
4470
|
+
status: "active" | "paused" | "archived";
|
|
4471
|
+
createdAt: string;
|
|
4472
|
+
updatedAt: string;
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
export interface ApiResponse<T> {
|
|
4476
|
+
success: boolean;
|
|
4477
|
+
data: T | null;
|
|
4478
|
+
error: string | null;
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
|
|
4482
|
+
meta: {
|
|
4483
|
+
page: number;
|
|
4484
|
+
limit: number;
|
|
4485
|
+
total: number;
|
|
4486
|
+
hasMore: boolean;
|
|
4487
|
+
};
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4490
|
+
// Validation \u2014 used by both frontend forms and backend handlers
|
|
4491
|
+
export const CreateProjectSchema = z.object({
|
|
4492
|
+
name: z.string().min(1, "Name is required").max(100).trim(),
|
|
4493
|
+
description: z.string().max(500).optional(),
|
|
4494
|
+
});
|
|
4495
|
+
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
|
4496
|
+
\`\`\`
|
|
4497
|
+
|
|
4498
|
+
## Reference: Typed API client (frontend)
|
|
4499
|
+
|
|
4500
|
+
\`\`\`ts
|
|
4501
|
+
// lib/api.ts
|
|
4502
|
+
class ApiClient {
|
|
4503
|
+
private baseUrl: string;
|
|
4504
|
+
|
|
4505
|
+
constructor(baseUrl = "/api") {
|
|
4506
|
+
this.baseUrl = baseUrl;
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4509
|
+
private async request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
|
4510
|
+
const res = await fetch(\`\${this.baseUrl}\${path}\`, {
|
|
4511
|
+
headers: {
|
|
4512
|
+
"Content-Type": "application/json",
|
|
4513
|
+
...opts.headers,
|
|
4514
|
+
},
|
|
4515
|
+
...opts,
|
|
4516
|
+
});
|
|
4517
|
+
|
|
4518
|
+
if (!res.ok) {
|
|
4519
|
+
const body = await res.json().catch(() => ({}));
|
|
4520
|
+
throw new ApiError(body.error ?? "Request failed", res.status);
|
|
4521
|
+
}
|
|
4522
|
+
|
|
4523
|
+
if (res.status === 204) return undefined as T;
|
|
4524
|
+
return res.json();
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
projects = {
|
|
4528
|
+
list: (params?: { page?: number; search?: string }) =>
|
|
4529
|
+
this.request<PaginatedResponse<Project>>(
|
|
4530
|
+
\`/projects?\${new URLSearchParams(params as Record<string, string>)}\`,
|
|
4531
|
+
),
|
|
4532
|
+
|
|
4533
|
+
getById: (id: string) =>
|
|
4534
|
+
this.request<ApiResponse<Project>>(\`/projects/\${id}\`),
|
|
4535
|
+
|
|
4536
|
+
create: (input: CreateProjectInput) =>
|
|
4537
|
+
this.request<ApiResponse<Project>>("/projects", {
|
|
4538
|
+
method: "POST",
|
|
4539
|
+
body: JSON.stringify(input),
|
|
4540
|
+
}),
|
|
4541
|
+
|
|
4542
|
+
delete: (id: string) =>
|
|
4543
|
+
this.request<void>(\`/projects/\${id}\`, { method: "DELETE" }),
|
|
4544
|
+
};
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
export const api = new ApiClient();
|
|
4548
|
+
\`\`\`
|
|
4549
|
+
|
|
4550
|
+
## Reference: Data fetching hook with SWR/React Query pattern
|
|
4551
|
+
|
|
4552
|
+
\`\`\`tsx
|
|
4553
|
+
function useProjects(params?: { page?: number; search?: string }) {
|
|
4554
|
+
const [state, setState] = useState<{
|
|
4555
|
+
data: Project[] | null;
|
|
4556
|
+
meta: PaginatedResponse<Project>["meta"] | null;
|
|
4557
|
+
loading: boolean;
|
|
4558
|
+
error: string | null;
|
|
4559
|
+
}>({ data: null, meta: null, loading: true, error: null });
|
|
4560
|
+
|
|
4561
|
+
const fetchProjects = useCallback(async () => {
|
|
4562
|
+
setState((s) => ({ ...s, loading: true, error: null }));
|
|
4563
|
+
try {
|
|
4564
|
+
const res = await api.projects.list(params);
|
|
4565
|
+
setState({ data: res.data, meta: res.meta, loading: false, error: null });
|
|
4566
|
+
} catch (err) {
|
|
4567
|
+
setState((s) => ({
|
|
4568
|
+
...s,
|
|
4569
|
+
loading: false,
|
|
4570
|
+
error: err instanceof ApiError ? err.message : "Failed to load projects",
|
|
4571
|
+
}));
|
|
4572
|
+
}
|
|
4573
|
+
}, [params?.page, params?.search]);
|
|
4574
|
+
|
|
4575
|
+
useEffect(() => { fetchProjects(); }, [fetchProjects]);
|
|
4576
|
+
|
|
4577
|
+
return { ...state, refetch: fetchProjects };
|
|
4578
|
+
}
|
|
4579
|
+
\`\`\`
|
|
4580
|
+
|
|
4581
|
+
## Reference: Optimistic delete with rollback
|
|
4582
|
+
|
|
4583
|
+
\`\`\`tsx
|
|
4584
|
+
function useDeleteProject(onSuccess?: () => void) {
|
|
4585
|
+
const [deleting, setDeleting] = useState<string | null>(null);
|
|
4586
|
+
|
|
4587
|
+
const deleteProject = async (id: string, projects: Project[], setProjects: (p: Project[]) => void) => {
|
|
4588
|
+
// Optimistic: remove from UI immediately
|
|
4589
|
+
const previous = projects;
|
|
4590
|
+
setProjects(projects.filter((p) => p.id !== id));
|
|
4591
|
+
setDeleting(id);
|
|
4592
|
+
|
|
4593
|
+
try {
|
|
4594
|
+
await api.projects.delete(id);
|
|
4595
|
+
onSuccess?.();
|
|
4596
|
+
} catch {
|
|
4597
|
+
// Rollback on failure
|
|
4598
|
+
setProjects(previous);
|
|
4599
|
+
toast.error("Failed to delete project");
|
|
4600
|
+
} finally {
|
|
4601
|
+
setDeleting(null);
|
|
4602
|
+
}
|
|
4603
|
+
};
|
|
4604
|
+
|
|
4605
|
+
return { deleteProject, deleting };
|
|
4606
|
+
}
|
|
4607
|
+
\`\`\`
|
|
4608
|
+
|
|
4609
|
+
## Quality checklist (reviewer must verify)
|
|
4610
|
+
- [ ] Types shared between frontend and backend \u2014 not duplicated
|
|
4611
|
+
- [ ] API client is typed \u2014 no raw fetch() in components
|
|
4612
|
+
- [ ] Validation schemas used on both sides
|
|
4613
|
+
- [ ] Server secrets never in NEXT_PUBLIC_ or client bundle
|
|
4614
|
+
- [ ] Loading, error, empty states in every data-fetching component
|
|
4615
|
+
- [ ] API errors return structured { success, data, error } \u2014 not raw strings
|
|
3562
4616
|
`;
|
|
3563
4617
|
SKILL_GENERAL = `# Code Quality Standard
|
|
3564
4618
|
|
|
@@ -3849,7 +4903,7 @@ function getSkillsForSpecialist(skills, specialist) {
|
|
|
3849
4903
|
function formatSkillsForPrompt(projectRoot) {
|
|
3850
4904
|
const skills = loadSkills(projectRoot);
|
|
3851
4905
|
if (skills.length === 0) return "";
|
|
3852
|
-
const maxChars =
|
|
4906
|
+
const maxChars = 24e3;
|
|
3853
4907
|
let totalChars = 0;
|
|
3854
4908
|
const parts = [];
|
|
3855
4909
|
for (const skill of skills) {
|
|
@@ -13330,6 +14384,42 @@ function App({ initialPrompt, modelPreference, agentMode: initialAgentMode, useO
|
|
|
13330
14384
|
}));
|
|
13331
14385
|
if (useOrchestrator) {
|
|
13332
14386
|
setIsRouting(false);
|
|
14387
|
+
if (!config2.isAuthenticated()) {
|
|
14388
|
+
setMessages((prev) => [
|
|
14389
|
+
...prev.filter((m) => m.id !== assistantMsgIdRef.current),
|
|
14390
|
+
{
|
|
14391
|
+
id: nextId(),
|
|
14392
|
+
role: "assistant",
|
|
14393
|
+
content: "Sign in to continue \u2014 opening browser..."
|
|
14394
|
+
}
|
|
14395
|
+
]);
|
|
14396
|
+
busyRef.current = false;
|
|
14397
|
+
setIsBusy(false);
|
|
14398
|
+
try {
|
|
14399
|
+
const { login: login2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
|
|
14400
|
+
await login2();
|
|
14401
|
+
if (config2.isAuthenticated()) {
|
|
14402
|
+
setMessages((prev) => [
|
|
14403
|
+
...prev,
|
|
14404
|
+
{ id: nextId(), role: "assistant", content: `Signed in as ${config2.get("email")}. Running your task...` }
|
|
14405
|
+
]);
|
|
14406
|
+
busyRef.current = true;
|
|
14407
|
+
setIsBusy(true);
|
|
14408
|
+
} else {
|
|
14409
|
+
setMessages((prev) => [
|
|
14410
|
+
...prev,
|
|
14411
|
+
{ id: nextId(), role: "assistant", content: "Sign in failed. Try `/login` or run `mint login` in another terminal." }
|
|
14412
|
+
]);
|
|
14413
|
+
return;
|
|
14414
|
+
}
|
|
14415
|
+
} catch {
|
|
14416
|
+
setMessages((prev) => [
|
|
14417
|
+
...prev,
|
|
14418
|
+
{ id: nextId(), role: "assistant", content: "Could not open browser. Run `mint login` in another terminal." }
|
|
14419
|
+
]);
|
|
14420
|
+
return;
|
|
14421
|
+
}
|
|
14422
|
+
}
|
|
13333
14423
|
try {
|
|
13334
14424
|
const { runOrchestrator: runOrchestrator2 } = await Promise.resolve().then(() => (init_loop2(), loop_exports));
|
|
13335
14425
|
let responseText = "";
|
|
@@ -13772,6 +14862,7 @@ var init_App = __esm({
|
|
|
13772
14862
|
init_useAgentEvents();
|
|
13773
14863
|
init_tiers();
|
|
13774
14864
|
init_types();
|
|
14865
|
+
init_config();
|
|
13775
14866
|
init_tracker();
|
|
13776
14867
|
init_pipeline();
|
|
13777
14868
|
initChalkLevel();
|
|
@@ -13912,166 +15003,12 @@ var init_dashboard = __esm({
|
|
|
13912
15003
|
});
|
|
13913
15004
|
|
|
13914
15005
|
// src/cli/index.ts
|
|
15006
|
+
init_auth();
|
|
13915
15007
|
import { Command } from "commander";
|
|
13916
15008
|
import chalk10 from "chalk";
|
|
13917
15009
|
import { readFileSync as readFileSync11, writeFileSync as writeFileSync8, mkdirSync as mkdirSync7 } from "fs";
|
|
13918
15010
|
import { dirname as dirname7, resolve as resolve11, sep as sep9 } from "path";
|
|
13919
15011
|
|
|
13920
|
-
// src/cli/commands/auth.ts
|
|
13921
|
-
init_config();
|
|
13922
|
-
import chalk from "chalk";
|
|
13923
|
-
import boxen from "boxen";
|
|
13924
|
-
import { createServer } from "http";
|
|
13925
|
-
var SUPABASE_URL = process.env.MINT_SUPABASE_URL ?? "https://srhoryezzsjmjdgfoxgd.supabase.co";
|
|
13926
|
-
var SUPABASE_ANON_KEY = process.env.MINT_SUPABASE_ANON_KEY ?? "";
|
|
13927
|
-
var AUTH_PAGE_URL = "https://usemint.dev/auth";
|
|
13928
|
-
var CALLBACK_PORT = 9876;
|
|
13929
|
-
async function login() {
|
|
13930
|
-
if (config2.isAuthenticated()) {
|
|
13931
|
-
const email = config2.get("email");
|
|
13932
|
-
console.log(chalk.yellow(`
|
|
13933
|
-
Already logged in as ${email}`));
|
|
13934
|
-
console.log(chalk.dim(" Run `mint logout` to switch accounts.\n"));
|
|
13935
|
-
return;
|
|
13936
|
-
}
|
|
13937
|
-
console.log(chalk.cyan("\n Opening browser to sign in...\n"));
|
|
13938
|
-
const token = await waitForOAuthCallback();
|
|
13939
|
-
if (!token) {
|
|
13940
|
-
console.log(chalk.red("\n Login failed. Try again with `mint login`.\n"));
|
|
13941
|
-
return;
|
|
13942
|
-
}
|
|
13943
|
-
try {
|
|
13944
|
-
const res = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
|
13945
|
-
headers: {
|
|
13946
|
-
"apikey": SUPABASE_ANON_KEY,
|
|
13947
|
-
"Authorization": `Bearer ${token}`
|
|
13948
|
-
}
|
|
13949
|
-
});
|
|
13950
|
-
if (!res.ok) {
|
|
13951
|
-
console.log(chalk.red("\n Invalid token received. Try again.\n"));
|
|
13952
|
-
return;
|
|
13953
|
-
}
|
|
13954
|
-
const user = await res.json();
|
|
13955
|
-
config2.setAll({
|
|
13956
|
-
apiKey: token,
|
|
13957
|
-
userId: user.id,
|
|
13958
|
-
email: user.email
|
|
13959
|
-
});
|
|
13960
|
-
console.log(boxen(
|
|
13961
|
-
`${chalk.bold.green("Signed in!")}
|
|
13962
|
-
|
|
13963
|
-
Email: ${chalk.cyan(user.email)}
|
|
13964
|
-
Plan: ${chalk.dim("Free \u2014 20 tasks/day")}
|
|
13965
|
-
|
|
13966
|
-
${chalk.dim("Run `mint` to start coding.")}`,
|
|
13967
|
-
{ padding: 1, borderColor: "green", borderStyle: "round" }
|
|
13968
|
-
));
|
|
13969
|
-
} catch (err) {
|
|
13970
|
-
console.log(chalk.red(`
|
|
13971
|
-
Error: ${err.message}
|
|
13972
|
-
`));
|
|
13973
|
-
}
|
|
13974
|
-
}
|
|
13975
|
-
function waitForOAuthCallback() {
|
|
13976
|
-
return new Promise((resolve12) => {
|
|
13977
|
-
const timeout = setTimeout(() => {
|
|
13978
|
-
server.close();
|
|
13979
|
-
resolve12(null);
|
|
13980
|
-
}, 12e4);
|
|
13981
|
-
const server = createServer(async (req, res) => {
|
|
13982
|
-
const url = new URL(req.url ?? "/", `http://localhost:${CALLBACK_PORT}`);
|
|
13983
|
-
if (url.pathname === "/callback") {
|
|
13984
|
-
let token = null;
|
|
13985
|
-
if (req.method === "POST") {
|
|
13986
|
-
const body = await new Promise((r) => {
|
|
13987
|
-
let data = "";
|
|
13988
|
-
req.on("data", (chunk) => {
|
|
13989
|
-
data += chunk.toString();
|
|
13990
|
-
});
|
|
13991
|
-
req.on("end", () => r(data));
|
|
13992
|
-
});
|
|
13993
|
-
try {
|
|
13994
|
-
const parsed = JSON.parse(body);
|
|
13995
|
-
token = parsed.access_token ?? parsed.token ?? null;
|
|
13996
|
-
} catch {
|
|
13997
|
-
token = null;
|
|
13998
|
-
}
|
|
13999
|
-
} else {
|
|
14000
|
-
token = url.searchParams.get("access_token") ?? url.searchParams.get("token");
|
|
14001
|
-
}
|
|
14002
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
14003
|
-
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
14004
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
14005
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
14006
|
-
res.end(`
|
|
14007
|
-
<html>
|
|
14008
|
-
<body style="background:#07090d;color:#c8dae8;font-family:monospace;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
|
|
14009
|
-
<div style="text-align:center">
|
|
14010
|
-
<h1 style="color:#00d4ff">Connected!</h1>
|
|
14011
|
-
<p>You can close this tab and return to the terminal.</p>
|
|
14012
|
-
</div>
|
|
14013
|
-
</body>
|
|
14014
|
-
</html>
|
|
14015
|
-
`);
|
|
14016
|
-
clearTimeout(timeout);
|
|
14017
|
-
server.close();
|
|
14018
|
-
resolve12(token);
|
|
14019
|
-
return;
|
|
14020
|
-
}
|
|
14021
|
-
if (req.method === "OPTIONS") {
|
|
14022
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
14023
|
-
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
14024
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
14025
|
-
res.writeHead(204);
|
|
14026
|
-
res.end();
|
|
14027
|
-
return;
|
|
14028
|
-
}
|
|
14029
|
-
res.writeHead(404);
|
|
14030
|
-
res.end("Not found");
|
|
14031
|
-
});
|
|
14032
|
-
server.listen(CALLBACK_PORT, () => {
|
|
14033
|
-
const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
|
|
14034
|
-
const authUrl = `${AUTH_PAGE_URL}?callback=${encodeURIComponent(callbackUrl)}`;
|
|
14035
|
-
import("child_process").then(({ execFile }) => {
|
|
14036
|
-
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
14037
|
-
execFile(cmd, [authUrl]);
|
|
14038
|
-
});
|
|
14039
|
-
});
|
|
14040
|
-
server.on("error", () => {
|
|
14041
|
-
clearTimeout(timeout);
|
|
14042
|
-
resolve12(null);
|
|
14043
|
-
});
|
|
14044
|
-
});
|
|
14045
|
-
}
|
|
14046
|
-
async function signup() {
|
|
14047
|
-
await login();
|
|
14048
|
-
}
|
|
14049
|
-
async function logout() {
|
|
14050
|
-
if (!config2.isAuthenticated()) {
|
|
14051
|
-
console.log(chalk.yellow("\n Not logged in.\n"));
|
|
14052
|
-
return;
|
|
14053
|
-
}
|
|
14054
|
-
const email = config2.get("email");
|
|
14055
|
-
config2.clear();
|
|
14056
|
-
console.log(chalk.green(`
|
|
14057
|
-
Logged out from ${email}
|
|
14058
|
-
`));
|
|
14059
|
-
}
|
|
14060
|
-
async function whoami() {
|
|
14061
|
-
if (!config2.isAuthenticated()) {
|
|
14062
|
-
console.log(chalk.yellow("\n Not logged in."));
|
|
14063
|
-
console.log(chalk.dim(" Run `mint login` to sign in.\n"));
|
|
14064
|
-
return;
|
|
14065
|
-
}
|
|
14066
|
-
const email = config2.get("email");
|
|
14067
|
-
console.log(boxen(
|
|
14068
|
-
`${chalk.bold("Signed in")}
|
|
14069
|
-
|
|
14070
|
-
Email: ${chalk.cyan(email)}`,
|
|
14071
|
-
{ padding: 1, borderColor: "cyan", borderStyle: "round" }
|
|
14072
|
-
));
|
|
14073
|
-
}
|
|
14074
|
-
|
|
14075
15012
|
// src/cli/commands/config.ts
|
|
14076
15013
|
init_config();
|
|
14077
15014
|
import chalk2 from "chalk";
|