yaver-cli 1.99.199 → 1.99.200

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": "yaver-cli",
3
- "version": "1.99.199",
3
+ "version": "1.99.200",
4
4
  "mcpName": "io.github.kivanccakmak/yaver",
5
5
  "description": "Unified npm bootstrap for the Yaver agent, SDK injection, and local-first developer runtime",
6
6
  "bin": {
@@ -0,0 +1,622 @@
1
+ 'use strict';
2
+
3
+ // `yaver deploy <target>` — friendly, monorepo-aware deploy front-end.
4
+ //
5
+ // This is the human-facing layer. It maps short target names
6
+ // (ios / android / convex / supabase / cloudflare / docker / npm /
7
+ // backend / frontend / mobile / all) onto concrete build+upload
8
+ // commands. `yaver.deploy.json` at the repo root is authoritative;
9
+ // on top of (or instead of) it, a find/grep monorepo scan auto-
10
+ // detects app dirs at any depth and their framework (Flutter, React
11
+ // Native/Expo, native Swift/Kotlin, Next/Astro/Vite/SvelteKit/Nuxt/
12
+ // Remix jamstack, Convex, Supabase, Cloudflare/Wrangler, Docker, and
13
+ // publishable npm libraries). Config entries always win on name
14
+ // conflicts; detection fills the gaps — so the same binary works for
15
+ // a bespoke monorepo (Talos) and a zero-config one (yaver.io).
16
+ // Anything we do not recognise (legacy `-repo/-workflow` CI-trigger
17
+ // flags, or future `generate/ship/runs/logs/diagnose` subcommands) is
18
+ // left for the Go agent — index.js decides the split.
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ const CONFIG_FILE = 'yaver.deploy.json';
25
+
26
+ // Subcommands that are NOT ours — index.js forwards these to the Go
27
+ // agent untouched so we never regress the existing CI-trigger `deploy`.
28
+ const AGENT_SUBCOMMANDS = new Set([
29
+ 'generate', 'ship', 'runs', 'logs', 'diagnose',
30
+ ]);
31
+
32
+ // Built-in alias → canonical target(s). A value may be an array of
33
+ // candidates; expand() picks the first one that actually resolved in
34
+ // this repo (so `backend` works whether it's Convex or Supabase).
35
+ // Overridable by config.aliases.
36
+ const BUILTIN_ALIASES = {
37
+ backend: ['convex', 'supabase'],
38
+ frontend: 'cloudflare',
39
+ front: 'cloudflare',
40
+ web: 'cloudflare',
41
+ library: 'npm',
42
+ lib: 'npm',
43
+ publish: 'npm',
44
+ container: 'docker',
45
+ compose: 'docker',
46
+ };
47
+
48
+ // Canonical target names we always recognise even before scanning.
49
+ const KNOWN_TARGETS = ['ios', 'android', 'convex', 'supabase', 'cloudflare', 'docker', 'npm'];
50
+
51
+ // Built-in groups. Overridable by config.groups. Expanded lazily so a
52
+ // group only pulls in targets that actually resolve.
53
+ const BUILTIN_GROUPS = {
54
+ mobile: ['ios', 'android'],
55
+ all: ['convex', 'cloudflare', 'ios', 'android'],
56
+ };
57
+
58
+ const DEPLOY_HELP = `
59
+ yaver deploy — build + ship a monorepo app from your own machine
60
+
61
+ Targets:
62
+ yaver deploy ios Mobile → TestFlight (RN/Expo, Flutter, native Swift)
63
+ yaver deploy android Mobile → Play internal (RN/Expo, Flutter, native Kotlin)
64
+ yaver deploy convex Deploy the Convex backend (alias: backend)
65
+ yaver deploy supabase Push Supabase db + edge functions (alias: backend)
66
+ yaver deploy cloudflare Deploy web/jamstack to Cloudflare (alias: frontend, front, web)
67
+ yaver deploy docker Build/up Docker (compose or image) (alias: container, compose)
68
+ yaver deploy npm Publish a detected npm library (alias: library, lib, publish)
69
+ yaver deploy mobile ios + android
70
+ yaver deploy all backend → frontend → mobile (npm/docker excluded)
71
+
72
+ Inspect:
73
+ yaver deploy list Show every resolved target, its framework + command
74
+ yaver deploy <t> --dry-run Print the exact dir + command without running it
75
+
76
+ Options:
77
+ --dry-run, --print Resolve + print, do not execute
78
+ --continue-on-error Run remaining targets even if one fails
79
+ --help Show this help
80
+
81
+ Targets come from ${CONFIG_FILE} at the repo root (authoritative).
82
+ A find/grep monorepo scan also auto-detects app dirs at any depth and
83
+ their framework; config entries win on name conflicts and detection
84
+ fills the gaps. Legacy CI-trigger flags (-repo/-workflow/...) and the
85
+ generate/ship/runs/logs/diagnose subcommands are handled by the agent.
86
+ `;
87
+
88
+ // A token is "ours" (handle locally) when it is a known target/alias/
89
+ // group name, or a config-defined one, or list/help with no agent
90
+ // flags. Anything starting with '-' or in AGENT_SUBCOMMANDS is the
91
+ // agent's. Used by index.js to route without parsing the world.
92
+ function isLocalDeployToken(token, args) {
93
+ if (!token) return false; // bare `yaver deploy` → agent (its usage)
94
+ if (token.startsWith('-')) {
95
+ return token === '--help' || token === '-h';
96
+ }
97
+ if (AGENT_SUBCOMMANDS.has(token)) return false;
98
+ if (token === 'list') return true;
99
+ if (BUILTIN_ALIASES[token] || BUILTIN_GROUPS[token]) return true;
100
+ if (KNOWN_TARGETS.includes(token)) return true;
101
+ // Fall back to config: a name only we'd know about.
102
+ try {
103
+ const { config } = loadConfig(process.cwd());
104
+ if (config) {
105
+ if (config.targets && config.targets[token]) return true;
106
+ if (config.groups && config.groups[token]) return true;
107
+ if (config.aliases && config.aliases[token]) return true;
108
+ }
109
+ } catch {
110
+ /* config errors surface later in run() with a clear message */
111
+ }
112
+ return false;
113
+ }
114
+
115
+ // Walk up from `start` until a yaver.deploy.json is found. If none,
116
+ // stop at the git root (or filesystem root) and return that as
117
+ // repoRoot with config=null so auto-detection can take over.
118
+ function loadConfig(start) {
119
+ let dir = path.resolve(start);
120
+ let gitRoot = null;
121
+ // eslint-disable-next-line no-constant-condition
122
+ while (true) {
123
+ const cfgPath = path.join(dir, CONFIG_FILE);
124
+ if (fs.existsSync(cfgPath)) {
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
128
+ } catch (err) {
129
+ throw new Error(`${CONFIG_FILE} at ${cfgPath} is not valid JSON: ${err.message}`);
130
+ }
131
+ return { repoRoot: dir, config: parsed, configPath: cfgPath };
132
+ }
133
+ if (!gitRoot && fs.existsSync(path.join(dir, '.git'))) gitRoot = dir;
134
+ const parent = path.dirname(dir);
135
+ if (parent === dir) break;
136
+ dir = parent;
137
+ }
138
+ return { repoRoot: gitRoot || path.resolve(start), config: null, configPath: null };
139
+ }
140
+
141
+ function readJson(p) {
142
+ try {
143
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ // Heavy dirs we never descend into during the scan.
150
+ const PRUNE_DIRS = [
151
+ 'node_modules', '.git', 'build', 'dist', '.next', '.expo', 'Pods',
152
+ '.yaver', 'vendor', 'target', '.gradle', 'DerivedData', '.turbo',
153
+ '.cache', 'out', '.svelte-kit', '.output', 'coverage', '.venv',
154
+ '__pycache__', '.dart_tool',
155
+ ];
156
+ // Marker files (any depth ≤ MAXDEPTH) that hint at an app + its stack.
157
+ const MARKER_FILES = [
158
+ 'package.json', 'pubspec.yaml', 'convex.json',
159
+ 'wrangler.toml', 'wrangler.jsonc', 'wrangler.json',
160
+ 'config.toml', 'Dockerfile', 'docker-compose.yml',
161
+ 'docker-compose.yaml', 'compose.yaml', 'compose.yml',
162
+ 'Package.swift', 'build.gradle', 'build.gradle.kts',
163
+ 'AndroidManifest.xml', 'netlify.toml', 'vercel.json',
164
+ 'next.config.*', 'astro.config.*', 'svelte.config.*',
165
+ 'nuxt.config.*', 'remix.config.*', 'gatsby-config.*', 'vite.config.*',
166
+ ];
167
+ // Marker directories.
168
+ const MARKER_DIRS = ['convex', 'supabase', '*.xcodeproj', '*.xcworkspace'];
169
+ const MAXDEPTH = 6;
170
+
171
+ function nameGroup(names) {
172
+ const g = ['('];
173
+ names.forEach((n, i) => {
174
+ if (i) g.push('-o');
175
+ g.push('-name', n);
176
+ });
177
+ g.push(')');
178
+ return g;
179
+ }
180
+
181
+ // One `find`: prune heavy dirs, then print marker files and marker
182
+ // dirs up to MAXDEPTH. Falls back to a shallow JS walk if `find` is
183
+ // unavailable (e.g. minimal Windows shells).
184
+ function scanRepo(repoRoot) {
185
+ const args = [
186
+ repoRoot, '-maxdepth', String(MAXDEPTH),
187
+ ...nameGroup(PRUNE_DIRS), '-prune', '-o',
188
+ '(', '-type', 'f', ...nameGroup(MARKER_FILES), ')', '-print', '-o',
189
+ '(', '-type', 'd', ...nameGroup(MARKER_DIRS), ')', '-print',
190
+ ];
191
+ const res = spawnSync('find', args, { encoding: 'utf8', timeout: 20000, maxBuffer: 16 * 1024 * 1024 });
192
+ if (res.error || res.status == null) {
193
+ return jsWalk(repoRoot, MAXDEPTH);
194
+ }
195
+ return res.stdout.split('\n').filter(Boolean);
196
+ }
197
+
198
+ // Minimal fallback walker (only marker basenames, bounded depth).
199
+ function jsWalk(root, depth, base = root, acc = []) {
200
+ if (depth < 0) return acc;
201
+ let entries;
202
+ try {
203
+ entries = fs.readdirSync(base, { withFileTypes: true });
204
+ } catch {
205
+ return acc;
206
+ }
207
+ for (const e of entries) {
208
+ if (e.isDirectory() && PRUNE_DIRS.includes(e.name)) continue;
209
+ const full = path.join(base, e.name);
210
+ const isMarkerDir = MARKER_DIRS.some((m) => m.startsWith('*')
211
+ ? e.name.endsWith(m.slice(1)) : e.name === m);
212
+ const isMarkerFile = MARKER_FILES.some((m) => m.startsWith('*.')
213
+ ? e.name.endsWith(m.slice(1)) : e.name === m);
214
+ if ((e.isDirectory() && isMarkerDir) || (e.isFile() && isMarkerFile)) acc.push(full);
215
+ if (e.isDirectory()) jsWalk(root, depth - 1, full, acc);
216
+ }
217
+ return acc;
218
+ }
219
+
220
+ function rel(repoRoot, p) {
221
+ const r = path.relative(repoRoot, p);
222
+ return r === '' ? '.' : r;
223
+ }
224
+ function depthOf(relDir) {
225
+ return relDir === '.' ? 0 : relDir.split(path.sep).length;
226
+ }
227
+
228
+ // Detect every deployable target by classifying scanned markers.
229
+ // Returns { [name]: spec } where spec = {dir, run, env, description,
230
+ // framework}. `run: null` means "detected but command unknown — user
231
+ // must declare it in yaver.deploy.json".
232
+ function autoDetectTargets(repoRoot) {
233
+ const markers = scanRepo(repoRoot);
234
+ const has = (rel2) => fs.existsSync(path.join(repoRoot, rel2));
235
+ const tfScript = has('scripts/deploy-testflight.sh');
236
+ const psScript = has('scripts/deploy-playstore.sh');
237
+
238
+ // Group markers by the directory that owns them.
239
+ const dirs = {}; // relDir -> { files:Set, dirs:Set }
240
+ const note = (d, kind, name) => {
241
+ const k = rel(repoRoot, d);
242
+ (dirs[k] || (dirs[k] = { files: new Set(), dirs: new Set() }))[kind].add(name);
243
+ };
244
+ for (const m of markers) {
245
+ let st;
246
+ try { st = fs.statSync(m); } catch { continue; }
247
+ if (st.isDirectory()) {
248
+ note(path.dirname(m), 'dirs', path.basename(m));
249
+ } else {
250
+ note(path.dirname(m), 'files', path.basename(m));
251
+ }
252
+ }
253
+
254
+ const targets = {};
255
+ // Keep the strongest candidate per target: prefer a repo deploy
256
+ // script, then a runnable command, then the shallowest dir.
257
+ const consider = (name, spec) => {
258
+ const cur = targets[name];
259
+ if (!cur) { targets[name] = spec; return; }
260
+ const score = (s) => (s.run ? 2 : 0) + (s.scriptBacked ? 1 : 0);
261
+ if (score(spec) > score(cur)
262
+ || (score(spec) === score(cur) && depthOf(spec.dir) < depthOf(cur.dir))) {
263
+ targets[name] = spec;
264
+ }
265
+ };
266
+
267
+ const isFile = (relDir, base) => {
268
+ const e = dirs[relDir];
269
+ if (!e) return false;
270
+ for (const f of e.files) {
271
+ if (f === base) return true;
272
+ if (base.startsWith('*.') && f.endsWith(base.slice(1))) return true;
273
+ }
274
+ return false;
275
+ };
276
+ const hasDir = (relDir, base) => {
277
+ const e = dirs[relDir];
278
+ if (!e) return false;
279
+ for (const dd of e.dirs) {
280
+ if (dd === base) return true;
281
+ if (base.startsWith('*') && dd.endsWith(base.slice(1))) return true;
282
+ }
283
+ return false;
284
+ };
285
+
286
+ for (const [relDir, sig] of Object.entries(dirs)) {
287
+ const abs = path.join(repoRoot, relDir);
288
+ const pkg = sig.files.has('package.json') ? readJson(path.join(abs, 'package.json')) : null;
289
+ const deps = pkg ? { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) } : {};
290
+ const scripts = (pkg && pkg.scripts) || {};
291
+ const pubspec = sig.files.has('pubspec.yaml');
292
+ let flutter = false;
293
+ if (pubspec) {
294
+ try { flutter = /\bflutter\s*:/.test(fs.readFileSync(path.join(abs, 'pubspec.yaml'), 'utf8')); }
295
+ catch { flutter = true; }
296
+ }
297
+
298
+ // ── Mobile ───────────────────────────────────────────────
299
+ const isExpo = !!deps.expo;
300
+ const isRN = !!deps['react-native'];
301
+ if (flutter) {
302
+ consider('ios', {
303
+ dir: tfScript ? '.' : relDir,
304
+ run: tfScript ? 'bash scripts/deploy-testflight.sh' : 'flutter build ipa',
305
+ scriptBacked: tfScript,
306
+ framework: 'flutter',
307
+ description: tfScript ? 'Flutter iOS via scripts/deploy-testflight.sh'
308
+ : 'Flutter → IPA (build only; declare TestFlight upload in ' + CONFIG_FILE + ')',
309
+ });
310
+ consider('android', {
311
+ dir: psScript ? '.' : relDir,
312
+ run: psScript ? 'bash scripts/deploy-playstore.sh' : 'flutter build appbundle',
313
+ scriptBacked: psScript,
314
+ framework: 'flutter',
315
+ description: psScript ? 'Flutter Android via scripts/deploy-playstore.sh'
316
+ : 'Flutter → AAB (build only; declare Play upload in ' + CONFIG_FILE + ')',
317
+ });
318
+ } else if (isExpo || isRN) {
319
+ const fw = isExpo ? 'expo' : 'react-native';
320
+ consider('ios', {
321
+ dir: '.',
322
+ run: tfScript ? 'bash scripts/deploy-testflight.sh' : null,
323
+ scriptBacked: tfScript,
324
+ framework: fw,
325
+ description: tfScript ? `${fw} iOS via scripts/deploy-testflight.sh`
326
+ : `${fw} app — declare iOS archive+upload in ${CONFIG_FILE}`,
327
+ });
328
+ consider('android', {
329
+ dir: '.',
330
+ run: psScript ? 'bash scripts/deploy-playstore.sh' : null,
331
+ scriptBacked: psScript,
332
+ framework: fw,
333
+ description: psScript ? `${fw} Android via scripts/deploy-playstore.sh`
334
+ : `${fw} app — declare Android bundle+upload in ${CONFIG_FILE}`,
335
+ });
336
+ } else if (hasDir(relDir, '*.xcodeproj') || hasDir(relDir, '*.xcworkspace') || sig.files.has('Package.swift')) {
337
+ consider('ios', {
338
+ dir: tfScript ? '.' : relDir,
339
+ run: tfScript ? 'bash scripts/deploy-testflight.sh' : null,
340
+ scriptBacked: tfScript,
341
+ framework: 'swift',
342
+ description: tfScript ? 'Native iOS via scripts/deploy-testflight.sh'
343
+ : 'Native Xcode project — declare archive+upload in ' + CONFIG_FILE,
344
+ });
345
+ }
346
+ if (!flutter && !isExpo && !isRN
347
+ && (sig.files.has('build.gradle') || sig.files.has('build.gradle.kts'))
348
+ && isFileInTree(dirs, 'AndroidManifest.xml')) {
349
+ consider('android', {
350
+ dir: psScript ? '.' : relDir,
351
+ run: psScript ? 'bash scripts/deploy-playstore.sh' : null,
352
+ scriptBacked: psScript,
353
+ framework: 'kotlin',
354
+ description: psScript ? 'Native Android via scripts/deploy-playstore.sh'
355
+ : 'Native Gradle project — declare bundle+upload in ' + CONFIG_FILE,
356
+ });
357
+ }
358
+
359
+ // ── Backend: Convex / Supabase ───────────────────────────
360
+ // Folder-based only: `convex` as a package dep is just the client
361
+ // SDK (a frontend), not the backend. The backend is where the
362
+ // functions/config live (convex.json or a convex/ dir).
363
+ if (sig.files.has('convex.json') || hasDir(relDir, 'convex')) {
364
+ consider('convex', {
365
+ dir: relDir, run: 'npx convex deploy -y', scriptBacked: false,
366
+ framework: 'convex', description: `Convex backend (${relDir})`,
367
+ });
368
+ }
369
+ if (hasDir(relDir, 'supabase') || (relDir.endsWith('supabase') && sig.files.has('config.toml'))) {
370
+ const sbDir = relDir.endsWith('supabase') ? path.dirname(relDir) || '.' : relDir;
371
+ consider('supabase', {
372
+ dir: sbDir,
373
+ run: 'npx supabase db push && npx supabase functions deploy',
374
+ scriptBacked: false, framework: 'supabase',
375
+ description: `Supabase db + edge functions (${sbDir}) — verify before live use`,
376
+ });
377
+ }
378
+
379
+ // ── Web / jamstack → Cloudflare ──────────────────────────
380
+ const jamstack = deps.next || deps.astro || deps.vite || deps['@sveltejs/kit']
381
+ || deps.nuxt || deps['@remix-run/dev'] || deps.gatsby;
382
+ const cfConfig = sig.files.has('wrangler.toml') || sig.files.has('wrangler.jsonc')
383
+ || sig.files.has('wrangler.json') || deps.wrangler || deps['@opennextjs/cloudflare'];
384
+ if (jamstack || cfConfig) {
385
+ let run = null;
386
+ let desc;
387
+ const fw = deps.next ? 'next' : deps.astro ? 'astro' : deps.nuxt ? 'nuxt'
388
+ : deps['@sveltejs/kit'] ? 'sveltekit' : deps['@remix-run/dev'] ? 'remix'
389
+ : deps.gatsby ? 'gatsby' : deps.vite ? 'vite' : 'jamstack';
390
+ if (scripts.deploy) { run = 'npm run deploy'; desc = `${fw} → Cloudflare (npm run deploy)`; }
391
+ else if (deps['@opennextjs/cloudflare']) { run = 'npx @opennextjs/cloudflare build && npx wrangler deploy'; desc = `${fw} → Cloudflare (OpenNext)`; }
392
+ else if (cfConfig) { run = 'npx wrangler deploy'; desc = `${fw} → Cloudflare (wrangler)`; }
393
+ else { desc = `${fw} jamstack — no Cloudflare config; declare deploy in ${CONFIG_FILE}`; }
394
+ consider('cloudflare', { dir: relDir, run, scriptBacked: false, framework: fw, description: desc });
395
+ }
396
+
397
+ // ── npm library ──────────────────────────────────────────
398
+ if (pkg && pkg.private !== true && pkg.name && pkg.version
399
+ && (pkg.main || pkg.module || pkg.exports || pkg.bin)
400
+ && !jamstack && !isExpo && !isRN) {
401
+ const pubCmd = scripts.release ? 'npm run release'
402
+ : scripts.publish ? 'npm run publish' : 'npm publish';
403
+ consider('npm', {
404
+ dir: relDir, run: pubCmd, scriptBacked: !!(scripts.release || scripts.publish),
405
+ framework: 'npm', description: `npm publish ${pkg.name}@${pkg.version} (${relDir})`,
406
+ });
407
+ }
408
+
409
+ // ── Docker ───────────────────────────────────────────────
410
+ const compose = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yaml', 'compose.yml']
411
+ .some((f) => sig.files.has(f));
412
+ if (compose) {
413
+ consider('docker', {
414
+ dir: relDir, run: 'docker compose up -d --build', scriptBacked: false,
415
+ framework: 'docker-compose', description: `Docker Compose (${relDir})`,
416
+ });
417
+ } else if (sig.files.has('Dockerfile')) {
418
+ const tag = (path.basename(abs) || 'app').toLowerCase().replace(/[^a-z0-9._-]/g, '-');
419
+ consider('docker', {
420
+ dir: relDir, run: `docker build -t ${tag} .`, scriptBacked: false,
421
+ framework: 'dockerfile', description: `Docker image build ${tag} (${relDir})`,
422
+ });
423
+ }
424
+ }
425
+
426
+ return targets;
427
+ }
428
+
429
+ // True if any scanned dir holds `base` (used for AndroidManifest.xml,
430
+ // which lives several levels under the gradle module root).
431
+ function isFileInTree(dirs, base) {
432
+ for (const e of Object.values(dirs)) if (e.files.has(base)) return true;
433
+ return false;
434
+ }
435
+
436
+ // Normalise detection + config into one table. Detection is the base;
437
+ // config.targets overlay it (config always wins on name conflicts) so
438
+ // the same binary serves a bespoke monorepo and a zero-config one.
439
+ function resolveTable(repoRoot, config) {
440
+ const targets = autoDetectTargets(repoRoot);
441
+
442
+ if (config && config.targets) {
443
+ for (const [name, spec] of Object.entries(config.targets)) {
444
+ targets[name] = {
445
+ dir: spec.dir || '.',
446
+ run: spec.run || (spec.script ? `bash ${spec.script}` : null),
447
+ env: spec.env || {},
448
+ description: spec.description || name,
449
+ framework: spec.framework || 'config',
450
+ aliases: spec.aliases || [],
451
+ };
452
+ }
453
+ }
454
+
455
+ const aliases = {};
456
+ for (const [a, v] of Object.entries(BUILTIN_ALIASES)) aliases[a] = v;
457
+ if (config && config.aliases) for (const [a, v] of Object.entries(config.aliases)) aliases[a] = v;
458
+ for (const [name, spec] of Object.entries(targets)) {
459
+ for (const a of spec.aliases || []) aliases[a] = name;
460
+ }
461
+
462
+ const groups = { ...BUILTIN_GROUPS, ...(config && config.groups) };
463
+
464
+ return { targets, groups, aliases };
465
+ }
466
+
467
+ // requested name → ordered, deduped list of concrete target names that
468
+ // actually exist in the table.
469
+ function expand(name, table, seen = new Set(), trail = []) {
470
+ if (trail.includes(name)) {
471
+ throw new Error(`circular deploy group: ${[...trail, name].join(' → ')}`);
472
+ }
473
+ // An alias may map to several candidates; pick the first that
474
+ // actually resolved in this repo (backend → convex|supabase).
475
+ let canonical = name;
476
+ const aliased = table.aliases[name];
477
+ if (Array.isArray(aliased)) {
478
+ canonical = aliased.find((c) => table.targets[c] || table.groups[c]) || aliased[0];
479
+ } else if (aliased) {
480
+ canonical = aliased;
481
+ }
482
+
483
+ if (table.targets[canonical]) {
484
+ if (!seen.has(canonical)) {
485
+ seen.add(canonical);
486
+ return [canonical];
487
+ }
488
+ return [];
489
+ }
490
+
491
+ const group = table.groups[canonical] || table.groups[name];
492
+ if (group) {
493
+ const out = [];
494
+ for (const member of group) {
495
+ out.push(...expand(member, table, seen, [...trail, name]));
496
+ }
497
+ return out;
498
+ }
499
+
500
+ return []; // unknown / not present in this repo
501
+ }
502
+
503
+ function printTarget(name, spec, repoRoot) {
504
+ const where = path.join(repoRoot, spec.dir || '.');
505
+ console.log(` ${name}${spec.framework ? ` [${spec.framework}]` : ''}`);
506
+ console.log(` dir: ${where}`);
507
+ if (spec.description) console.log(` what: ${spec.description}`);
508
+ if (spec.run) {
509
+ console.log(` run: ${spec.run}`);
510
+ } else {
511
+ console.log(` run: ⚠️ not configured — ${spec.description}`);
512
+ }
513
+ if (spec.env && Object.keys(spec.env).length) {
514
+ console.log(` env: ${Object.keys(spec.env).join(', ')}`);
515
+ }
516
+ }
517
+
518
+ function runOne(name, spec, repoRoot) {
519
+ const cwd = path.join(repoRoot, spec.dir || '.');
520
+ if (!spec.run) {
521
+ throw new Error(`target "${name}" has no command (${spec.description}). Add it to ${CONFIG_FILE}.`);
522
+ }
523
+ if (!fs.existsSync(cwd)) {
524
+ throw new Error(`target "${name}" dir does not exist: ${cwd}`);
525
+ }
526
+ console.log(`\n── deploy ${name} ──`);
527
+ console.log(` $ (cd ${cwd} && ${spec.run})\n`);
528
+ const res = spawnSync('bash', ['-lc', spec.run], {
529
+ cwd,
530
+ stdio: 'inherit',
531
+ env: { ...process.env, ...(spec.env || {}) },
532
+ });
533
+ if (res.error) throw new Error(`failed to launch "${name}": ${res.error.message}`);
534
+ return res.status == null ? 1 : res.status;
535
+ }
536
+
537
+ async function deploy(args) {
538
+ if (!args.length || args[0] === '--help' || args[0] === '-h') {
539
+ console.log(DEPLOY_HELP);
540
+ process.exit(0);
541
+ }
542
+
543
+ const dryRun = args.includes('--dry-run') || args.includes('--print');
544
+ const continueOnError = args.includes('--continue-on-error');
545
+ const positionals = args.filter((a) => !a.startsWith('-'));
546
+ const requested = positionals[0];
547
+
548
+ const { repoRoot, config, configPath } = loadConfig(process.cwd());
549
+ const table = resolveTable(repoRoot, config);
550
+
551
+ const source = configPath
552
+ ? path.relative(process.cwd(), configPath) || CONFIG_FILE
553
+ : 'auto-detection (no ' + CONFIG_FILE + ')';
554
+
555
+ if (requested === 'list' || (!requested && dryRun)) {
556
+ console.log(`\nDeploy targets — source: ${source}`);
557
+ console.log(`Repo root: ${repoRoot}\n`);
558
+ const names = Object.keys(table.targets);
559
+ if (!names.length) {
560
+ console.log(' (none resolved — add a ' + CONFIG_FILE + ' or check the layout)');
561
+ }
562
+ for (const n of names) printTarget(n, table.targets[n], repoRoot);
563
+ console.log('\nGroups:');
564
+ for (const [g, members] of Object.entries(table.groups)) {
565
+ console.log(` ${g}: ${members.join(', ')}`);
566
+ }
567
+ console.log('\nAliases:');
568
+ for (const [a, t] of Object.entries(table.aliases)) {
569
+ console.log(` ${a} → ${t}`);
570
+ }
571
+ console.log('');
572
+ process.exit(0);
573
+ }
574
+
575
+ const plan = expand(requested, table);
576
+ if (!plan.length) {
577
+ console.error(`\n❌ "${requested}" resolved to no deployable targets.`);
578
+ console.error(` Source: ${source}`);
579
+ console.error(` Known targets: ${Object.keys(table.targets).join(', ') || '(none)'}`);
580
+ console.error(` Run: yaver deploy list`);
581
+ process.exit(1);
582
+ }
583
+
584
+ console.log(`\nyaver deploy ${requested} → ${plan.join(', ')}`);
585
+ console.log(`Source: ${source} · Repo root: ${repoRoot}`);
586
+
587
+ if (dryRun) {
588
+ console.log('\n(dry-run — nothing will be executed)\n');
589
+ for (const n of plan) printTarget(n, table.targets[n], repoRoot);
590
+ console.log('');
591
+ process.exit(0);
592
+ }
593
+
594
+ const results = [];
595
+ for (const n of plan) {
596
+ let status;
597
+ try {
598
+ status = runOne(n, table.targets[n], repoRoot);
599
+ } catch (err) {
600
+ console.error(`\n❌ ${err.message}`);
601
+ status = 1;
602
+ }
603
+ results.push({ target: n, status });
604
+ if (status !== 0 && !continueOnError) {
605
+ console.error(`\n❌ ${n} failed (exit ${status}); stopping. Use --continue-on-error to override.`);
606
+ break;
607
+ }
608
+ }
609
+
610
+ console.log('\n── composite summary ──');
611
+ let worst = 0;
612
+ for (const r of results) {
613
+ console.log(` ${r.status === 0 ? '✓' : '✗'} ${r.target} (exit ${r.status})`);
614
+ if (r.status !== 0) worst = r.status;
615
+ }
616
+ const skipped = plan.length - results.length;
617
+ if (skipped > 0) console.log(` · ${skipped} target(s) skipped after failure`);
618
+ console.log('');
619
+ process.exit(worst);
620
+ }
621
+
622
+ module.exports = { deploy, isLocalDeployToken, DEPLOY_HELP };
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ const { modules } = require('./commands/modules');
8
8
  const { reset } = require('./commands/reset');
9
9
  const { status } = require('./commands/status');
10
10
  const { feedback } = require('./commands/feedback');
11
+ const { deploy, isLocalDeployToken } = require('./commands/deploy');
11
12
 
12
13
  const PUSH_HELP = `
13
14
  yaver push — Push existing React Native projects to the Yaver mobile host
@@ -53,6 +54,19 @@ Push-to-device commands:
53
54
  yaver push reset Clear pushed bundle on the selected device
54
55
  yaver push status Show project + device push status
55
56
 
57
+ Deploy commands (monorepo-aware; yaver.deploy.json + find/grep scan):
58
+ yaver deploy ios Mobile → TestFlight (RN/Expo, Flutter, Swift)
59
+ yaver deploy android Mobile → Play internal (RN/Expo, Flutter, Kotlin)
60
+ yaver deploy convex Deploy Convex backend (alias: backend)
61
+ yaver deploy supabase Supabase db + edge functions (alias: backend)
62
+ yaver deploy cloudflare Web/jamstack → Cloudflare (alias: frontend, front)
63
+ yaver deploy docker Docker compose/image (alias: container, compose)
64
+ yaver deploy npm Publish detected npm library (alias: lib, publish)
65
+ yaver deploy mobile ios + android
66
+ yaver deploy all backend → frontend → mobile (npm/docker excluded)
67
+ yaver deploy list Show resolved targets, framework + commands
68
+ yaver deploy <t> --dry-run Print dir + command without running
69
+
56
70
  Feedback SDK commands (web and mobile SDKs are separate packages):
57
71
  yaver feedback init Install the right feedback SDK for this project
58
72
  yaver feedback init --platform web Force web (yaver-feedback-web)
@@ -166,6 +180,20 @@ async function runUnified(args) {
166
180
  return;
167
181
  }
168
182
 
183
+ if (command === 'deploy') {
184
+ // Friendly monorepo targets (ios/android/convex/cloudflare/
185
+ // mobile/all/aliases/list/--help) are handled locally. Legacy
186
+ // CI-trigger flags (-repo/-workflow/...) and future agent
187
+ // subcommands (generate/ship/runs/logs/diagnose) fall through to
188
+ // the Go agent untouched.
189
+ if (isLocalDeployToken(args[1], args)) {
190
+ await deploy(args.slice(1));
191
+ return;
192
+ }
193
+ await runAgentCommand(args);
194
+ return;
195
+ }
196
+
169
197
  if (command === 'npm-agent-info') {
170
198
  const info = resolveAgentInfo();
171
199
  console.log(JSON.stringify(info, null, 2));