atris 3.38.0 → 3.40.0

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.
Files changed (77) hide show
  1. package/AGENTS.md +25 -6
  2. package/atris/PERSONA.md +8 -4
  3. package/atris.md +7 -0
  4. package/ax +2 -1
  5. package/bin/atris.js +30 -5
  6. package/commands/agent-spawn.js +13 -11
  7. package/commands/autoland.js +28 -77
  8. package/commands/bench.js +10 -12
  9. package/commands/business.js +345 -0
  10. package/commands/chat-scan.js +5 -7
  11. package/commands/codex-goal.js +8 -10
  12. package/commands/console.js +19 -3
  13. package/commands/decide.js +166 -0
  14. package/commands/deck.js +1 -4
  15. package/commands/drill.js +14 -24
  16. package/commands/engine.js +196 -8
  17. package/commands/gm.js +8 -6
  18. package/commands/harvest.js +1 -4
  19. package/commands/init.js +23 -3
  20. package/commands/land.js +8 -14
  21. package/commands/launchpad.js +1 -14
  22. package/commands/lifecycle.js +5 -5
  23. package/commands/log.js +55 -5
  24. package/commands/member.js +558 -574
  25. package/commands/mission.js +456 -237
  26. package/commands/pack.js +2746 -164
  27. package/commands/play.js +6 -4
  28. package/commands/probe.js +2 -2
  29. package/commands/pulse.js +15 -16
  30. package/commands/release.js +10 -9
  31. package/commands/router.js +5 -4
  32. package/commands/site-deploy.js +870 -0
  33. package/commands/site.js +11 -2
  34. package/commands/slop.js +14 -2
  35. package/commands/stream.js +4 -18
  36. package/commands/task.js +880 -558
  37. package/commands/taste.js +101 -0
  38. package/commands/team.js +83 -3
  39. package/commands/vercel.js +4 -2
  40. package/commands/voice.js +195 -0
  41. package/commands/watch.js +1 -22
  42. package/commands/wiki.js +1 -4
  43. package/commands/workflow.js +2 -2
  44. package/commands/worktree.js +1 -14
  45. package/commands/xp.js +27 -24
  46. package/lib/accept-verify-gate.js +5 -1
  47. package/lib/arg-parser.js +41 -0
  48. package/lib/auto-accept-certified.js +116 -1
  49. package/lib/autoland.js +66 -0
  50. package/lib/bench/runner.js +19 -1
  51. package/lib/context-gatherer.js +7 -1
  52. package/lib/engine-registry.js +141 -20
  53. package/lib/falsifier-probe.js +84 -0
  54. package/lib/fleet.js +65 -15
  55. package/lib/git-spawn.js +15 -0
  56. package/lib/json-file.js +37 -0
  57. package/lib/known-commands.js +2 -2
  58. package/lib/lesson-preflight.js +146 -0
  59. package/lib/loop-doctor.js +0 -2
  60. package/lib/mission-human-asks.js +28 -0
  61. package/lib/mission-protected-lane.js +4 -1
  62. package/lib/official-cli-integration.js +47 -2
  63. package/lib/orb-context.js +8 -1
  64. package/lib/pack-capabilities.js +685 -0
  65. package/lib/router-brain.js +51 -1
  66. package/lib/runner-command.js +0 -6
  67. package/lib/self-drive.js +44 -13
  68. package/lib/task-db.js +137 -3
  69. package/lib/task-decision.js +50 -0
  70. package/lib/taste-lessons.js +153 -0
  71. package/lib/tool-result-encode.js +17 -1
  72. package/lib/voice-gate.js +66 -0
  73. package/lib/wish-audit.js +1 -1
  74. package/lib/wish-delegate.js +1 -1
  75. package/lib/zip.js +95 -7
  76. package/package.json +2 -1
  77. package/templates/business-starter/persona.md +9 -0
@@ -0,0 +1,870 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+ const { loadCredentials } = require('../utils/auth');
8
+ const { httpRequest } = require('../utils/api');
9
+
10
+ const DEFAULT_API_BASE = 'https://api.atris.ai';
11
+ const RENDER_API_BASE = 'https://api.render.com/v1';
12
+ const RENDER_SERVICE_ID = 'srv-culkutq3esus73cvqcg0';
13
+ const RENDER_POLL_INTERVAL_MS = 15000;
14
+ const RENDER_POLL_TIMEOUT_MS = 10 * 60 * 1000;
15
+ const GITHUB_ORG = 'atrislabs';
16
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
17
+ const MAX_FILES = 200;
18
+ const BATCH_SIZE = 50;
19
+ const RESERVED_NAMES = new Set([
20
+ 'api', 'www', 'app', 'share', 'admin', 'mail', 'staging', 'status',
21
+ 'docs', 'blog', 'dev', 'dashboard', 'auth', 'cdn', 'assets',
22
+ ]);
23
+
24
+ const TEXT_CONTENT_TYPES = Object.freeze({
25
+ '.html': 'text/html',
26
+ '.css': 'text/css',
27
+ '.js': 'application/javascript',
28
+ '.mjs': 'application/javascript',
29
+ '.svg': 'image/svg+xml',
30
+ '.txt': 'text/plain',
31
+ '.json': 'application/json',
32
+ '.xml': 'application/xml',
33
+ '.webmanifest': 'application/manifest+json',
34
+ '.map': 'application/json',
35
+ });
36
+
37
+ const BINARY_CONTENT_TYPES = Object.freeze({
38
+ '.png': 'image/png',
39
+ '.jpg': 'image/jpeg',
40
+ '.jpeg': 'image/jpeg',
41
+ '.gif': 'image/gif',
42
+ '.webp': 'image/webp',
43
+ '.ico': 'image/x-icon',
44
+ '.woff': 'font/woff',
45
+ '.woff2': 'font/woff2',
46
+ '.ttf': 'font/ttf',
47
+ '.otf': 'font/otf',
48
+ });
49
+
50
+ function printHelp() {
51
+ console.log(`
52
+ atris site deploy: publish a web folder at a free atris.ai subdomain
53
+
54
+ atris site deploy <dir> --name <slug> [--spa] [--dry-run]
55
+ atris site deploy <dir> --fullstack --name <slug> [--dry-run]
56
+ atris site deploy dist --name my-site --api-base https://api.atris.ai
57
+
58
+ names use lowercase letters, digits, and hyphens. files are capped at 2 mb
59
+ each and 200 total. --spa enables single-page app routing. --fullstack
60
+ deploys a node server with a package.json start script.
61
+ `);
62
+ }
63
+
64
+ function parseArgs(argv) {
65
+ const options = {
66
+ dir: null,
67
+ name: null,
68
+ apiBase: DEFAULT_API_BASE,
69
+ spa: false,
70
+ fullstack: false,
71
+ dryRun: false,
72
+ help: false,
73
+ };
74
+
75
+ for (let i = 0; i < argv.length; i += 1) {
76
+ const arg = argv[i];
77
+ if (arg === '--help' || arg === 'help') {
78
+ options.help = true;
79
+ } else if (arg === '--spa') {
80
+ options.spa = true;
81
+ } else if (arg === '--fullstack') {
82
+ options.fullstack = true;
83
+ } else if (arg === '--dry-run') {
84
+ options.dryRun = true;
85
+ } else if (arg === '--name' || arg === '--api-base') {
86
+ const value = argv[i + 1];
87
+ if (!value || value.startsWith('-')) throw new Error(`${arg} needs a value`);
88
+ if (arg === '--name') options.name = value;
89
+ else options.apiBase = value;
90
+ i += 1;
91
+ } else if (arg.startsWith('-')) {
92
+ throw new Error(`unknown option: ${arg}`);
93
+ } else if (!options.dir) {
94
+ options.dir = arg;
95
+ } else {
96
+ throw new Error(`unexpected argument: ${arg}`);
97
+ }
98
+ }
99
+
100
+ return options;
101
+ }
102
+
103
+ function validateSlug(slug) {
104
+ if (!slug) return '--name is required';
105
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(slug)) {
106
+ return 'name must use lowercase letters, digits, and hyphens, with no hyphen at either end';
107
+ }
108
+ if (RESERVED_NAMES.has(slug)) return `name is reserved: ${slug}`;
109
+ return null;
110
+ }
111
+
112
+ function validateApiBase(value) {
113
+ try {
114
+ const parsed = new URL(value);
115
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('bad protocol');
116
+ return parsed.toString().replace(/\/$/, '');
117
+ } catch {
118
+ throw new Error('--api-base must be a valid http or https url');
119
+ }
120
+ }
121
+
122
+ function relativePath(root, filePath) {
123
+ return path.relative(root, filePath).split(path.sep).join('/');
124
+ }
125
+
126
+ function formatBytes(bytes) {
127
+ if (bytes < 1024) return `${bytes} b`;
128
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kb`;
129
+ return `${(bytes / (1024 * 1024)).toFixed(1)} mb`;
130
+ }
131
+
132
+ function skip(relative, reason, log) {
133
+ log(` skipped ${relative}: ${reason}`);
134
+ }
135
+
136
+ function collectPages(root, deps = {}) {
137
+ const fileSystem = deps.fs || fs;
138
+ const log = deps.log || console.log;
139
+ const pages = [];
140
+
141
+ function walk(current) {
142
+ const entries = fileSystem.readdirSync(current, { withFileTypes: true })
143
+ .sort((a, b) => a.name.localeCompare(b.name));
144
+
145
+ for (const entry of entries) {
146
+ const absolute = path.join(current, entry.name);
147
+ const relative = relativePath(root, absolute);
148
+ const shown = entry.isDirectory() ? `${relative}/` : relative;
149
+
150
+ if (entry.name === 'node_modules' || entry.name === '.git') {
151
+ skip(shown, 'ignored directory', log);
152
+ continue;
153
+ }
154
+ if (entry.name.startsWith('.')) {
155
+ skip(shown, 'dotfiles are ignored', log);
156
+ continue;
157
+ }
158
+ if (entry.isSymbolicLink()) {
159
+ skip(shown, 'symbolic links are ignored', log);
160
+ continue;
161
+ }
162
+ if (entry.isDirectory()) {
163
+ walk(absolute);
164
+ continue;
165
+ }
166
+ if (!entry.isFile()) {
167
+ skip(relative, 'not a regular file', log);
168
+ continue;
169
+ }
170
+
171
+ const extension = path.extname(entry.name).toLowerCase();
172
+ const contentType = TEXT_CONTENT_TYPES[extension] || BINARY_CONTENT_TYPES[extension];
173
+ if (!contentType) {
174
+ skip(relative, 'unsupported file type', log);
175
+ continue;
176
+ }
177
+
178
+ let size;
179
+ try {
180
+ size = fileSystem.statSync(absolute).size;
181
+ if (typeof fileSystem.accessSync === 'function') {
182
+ fileSystem.accessSync(absolute, (fileSystem.constants || fs.constants).R_OK);
183
+ }
184
+ } catch (error) {
185
+ skip(relative, `could not read file: ${error.message}`, log);
186
+ continue;
187
+ }
188
+ if (size > MAX_FILE_BYTES) {
189
+ skip(relative, `larger than ${formatBytes(MAX_FILE_BYTES)}`, log);
190
+ continue;
191
+ }
192
+ if (pages.length >= MAX_FILES) {
193
+ skip(relative, `file limit of ${MAX_FILES} reached`, log);
194
+ continue;
195
+ }
196
+
197
+ const isBase64 = Object.prototype.hasOwnProperty.call(BINARY_CONTENT_TYPES, extension);
198
+ pages.push({
199
+ path: relative,
200
+ sourcePath: absolute,
201
+ content_type: contentType,
202
+ is_base64: isBase64,
203
+ size,
204
+ });
205
+ }
206
+ }
207
+
208
+ walk(root);
209
+ return pages;
210
+ }
211
+
212
+ function responseText(result) {
213
+ const body = result && result.body;
214
+ if (!body) return '';
215
+ return Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
216
+ }
217
+
218
+ function parseResponseData(result) {
219
+ const text = responseText(result);
220
+ if (!text.trim()) return null;
221
+ try { return JSON.parse(text); } catch { return null; }
222
+ }
223
+
224
+ function errorDetail(result) {
225
+ const data = parseResponseData(result);
226
+ if (data && typeof data === 'object') {
227
+ const detail = data.detail || data.error || data.message;
228
+ if (detail) return typeof detail === 'string' ? detail : JSON.stringify(detail);
229
+ }
230
+ return responseText(result).trim() || 'request failed';
231
+ }
232
+
233
+ async function requestJson(method, url, token, body, deps = {}) {
234
+ const request = deps.httpRequest || httpRequest;
235
+ const payload = body === undefined ? null : JSON.stringify(body);
236
+ return request(url, {
237
+ method,
238
+ headers: {
239
+ Authorization: `Bearer ${token}`,
240
+ Accept: 'application/json',
241
+ 'Content-Type': 'application/json',
242
+ },
243
+ ...(payload === null ? {} : { body: payload }),
244
+ timeoutMs: 60000,
245
+ });
246
+ }
247
+
248
+ function requestError(method, url, result) {
249
+ return new Error(`${method} ${url} failed (${result.status || 0}): ${errorDetail(result)}`);
250
+ }
251
+
252
+ async function setSpa(sitesUrl, slug, token, deps = {}) {
253
+ const log = deps.log || console.log;
254
+ const url = `${sitesUrl}/${slug}`;
255
+ let result;
256
+ try {
257
+ result = await requestJson('PATCH', url, token, { spa: true }, deps);
258
+ } catch (error) {
259
+ log(` warning: spa routing could not be enabled: ${error.message}`);
260
+ return;
261
+ }
262
+ if (result.status < 200 || result.status >= 300) {
263
+ log(` warning: spa routing was not accepted by the server: ${errorDetail(result)}`);
264
+ }
265
+ }
266
+
267
+ async function createSite(sitesUrl, slug, spa, token, deps = {}) {
268
+ const log = deps.log || console.log;
269
+ const body = spa ? { slug, spa: true } : { slug };
270
+ let result = await requestJson('POST', sitesUrl, token, body, deps);
271
+
272
+ if (result.status >= 200 && result.status < 300) {
273
+ log(` created site ${slug}`);
274
+ return;
275
+ }
276
+
277
+ if (result.status === 409) {
278
+ log(` site ${slug} already exists`);
279
+ if (spa) await setSpa(sitesUrl, slug, token, deps);
280
+ return;
281
+ }
282
+
283
+ if (spa && (result.status === 400 || result.status === 422)) {
284
+ const rejectedSpa = errorDetail(result);
285
+ result = await requestJson('POST', sitesUrl, token, { slug }, deps);
286
+ if ((result.status >= 200 && result.status < 300) || result.status === 409) {
287
+ log(` warning: spa routing was not accepted during site creation: ${rejectedSpa}`);
288
+ if (result.status === 409) {
289
+ log(` site ${slug} already exists`);
290
+ await setSpa(sitesUrl, slug, token, deps);
291
+ } else {
292
+ log(` created site ${slug}`);
293
+ }
294
+ return;
295
+ }
296
+ }
297
+
298
+ throw requestError('POST', sitesUrl, result);
299
+ }
300
+
301
+ function pagePayload(page, fileSystem) {
302
+ const bytes = fileSystem.readFileSync(page.sourcePath);
303
+ return {
304
+ path: page.path,
305
+ content: page.is_base64 ? bytes.toString('base64') : bytes.toString('utf8'),
306
+ content_type: page.content_type,
307
+ is_base64: page.is_base64,
308
+ };
309
+ }
310
+
311
+ async function uploadPages(sitesUrl, slug, pages, token, deps = {}) {
312
+ const fileSystem = deps.fs || fs;
313
+ const log = deps.log || console.log;
314
+ const url = `${sitesUrl}/${slug}/pages`;
315
+ for (let start = 0; start < pages.length; start += BATCH_SIZE) {
316
+ const batch = pages.slice(start, start + BATCH_SIZE);
317
+ const result = await requestJson('PUT', url, token, {
318
+ pages: batch.map((page) => pagePayload(page, fileSystem)),
319
+ }, deps);
320
+ if (result.status < 200 || result.status >= 300) throw requestError('PUT', url, result);
321
+ for (const page of batch) log(` published ${page.path} (${formatBytes(page.size)})`);
322
+ }
323
+ }
324
+
325
+ function parseYamlScalar(value) {
326
+ const trimmed = value.trim();
327
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
328
+ try { return JSON.parse(trimmed); } catch { return null; }
329
+ }
330
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
331
+ return trimmed.slice(1, -1).replace(/''/g, "'");
332
+ }
333
+ return trimmed.split(/\s+#/)[0].trim() || null;
334
+ }
335
+
336
+ function renderApiKey(yaml) {
337
+ const lines = String(yaml).split(/\r?\n/);
338
+ let apiIndent = null;
339
+ for (const line of lines) {
340
+ if (!line.trim() || line.trim().startsWith('#')) continue;
341
+ const indent = line.match(/^\s*/)[0].length;
342
+ if (apiIndent === null) {
343
+ if (/^\s*api:\s*(?:#.*)?$/.test(line)) apiIndent = indent;
344
+ continue;
345
+ }
346
+ if (indent <= apiIndent) break;
347
+ const match = line.match(/^\s*key:\s*(.*?)\s*$/);
348
+ if (match) return parseYamlScalar(match[1]);
349
+ }
350
+ return null;
351
+ }
352
+
353
+ function loadRenderApiKey(deps = {}) {
354
+ if (deps.renderApiKey) return deps.renderApiKey;
355
+ const fileSystem = deps.fs || fs;
356
+ const home = deps.homedir ? deps.homedir() : os.homedir();
357
+ const configPath = path.join(home, '.render', 'cli.yaml');
358
+ if (!fileSystem.existsSync(configPath)) {
359
+ throw new Error('~/.render/cli.yaml was not found');
360
+ }
361
+ let yaml;
362
+ try {
363
+ yaml = fileSystem.readFileSync(configPath, 'utf8');
364
+ } catch (error) {
365
+ throw new Error(`the render config could not be read: ${error.message}`);
366
+ }
367
+ const apiKey = renderApiKey(yaml);
368
+ if (!apiKey) throw new Error('the render api key was not found in ~/.render/cli.yaml');
369
+ return apiKey;
370
+ }
371
+
372
+ function alreadyExists(result) {
373
+ return result.status === 409 || /already[\s_-]*(?:exists|registered)|conflict/i.test(errorDetail(result));
374
+ }
375
+
376
+ function withoutSecret(value, secret) {
377
+ const text = String(value || '');
378
+ return secret ? text.split(secret).join('[redacted]') : text;
379
+ }
380
+
381
+ async function registerSubdomain(slug, deps = {}) {
382
+ const log = deps.log || console.log;
383
+ let apiKey;
384
+ try {
385
+ apiKey = loadRenderApiKey(deps);
386
+ } catch (error) {
387
+ log(` warning: subdomain registration skipped because ${error.message}`);
388
+ return;
389
+ }
390
+
391
+ const base = `${RENDER_API_BASE}/services/${RENDER_SERVICE_ID}/custom-domains`;
392
+ let created;
393
+ try {
394
+ created = await requestJson('POST', base, apiKey, { name: `${slug}.atris.ai` }, deps);
395
+ } catch (error) {
396
+ log(` warning: subdomain registration failed: ${withoutSecret(error.message, apiKey)}`);
397
+ return;
398
+ }
399
+
400
+ const data = parseResponseData(created);
401
+ const id = data && (data.id || (data.customDomain && data.customDomain.id));
402
+ if (alreadyExists(created)) {
403
+ log(` subdomain ${slug}.atris.ai is already registered`);
404
+ if (!id) return;
405
+ } else if (created.status < 200 || created.status >= 300) {
406
+ log(` warning: subdomain registration failed (${created.status || 0}): ${withoutSecret(errorDetail(created), apiKey)}`);
407
+ return;
408
+ } else {
409
+ log(` registered subdomain ${slug}.atris.ai`);
410
+ }
411
+
412
+ if (!id) return;
413
+
414
+ const verifyUrl = `${base}/${encodeURIComponent(id)}/verify`;
415
+ try {
416
+ const verified = await requestJson('POST', verifyUrl, apiKey, undefined, deps);
417
+ if (verified.status >= 200 && verified.status < 300) {
418
+ log(` verified subdomain ${slug}.atris.ai`);
419
+ } else {
420
+ log(` warning: subdomain verification failed (${verified.status || 0}): ${withoutSecret(errorDetail(verified), apiKey)}`);
421
+ }
422
+ } catch (error) {
423
+ log(` warning: subdomain verification failed: ${withoutSecret(error.message, apiKey)}`);
424
+ }
425
+ }
426
+
427
+ function fullstackPackage(root, deps = {}) {
428
+ const fileSystem = deps.fs || fs;
429
+ const packagePath = path.join(root, 'package.json');
430
+ if (!fileSystem.existsSync(packagePath)) {
431
+ throw new Error('fullstack deploy needs package.json. add package.json with a "start" script.');
432
+ }
433
+
434
+ let packageJson;
435
+ try {
436
+ packageJson = JSON.parse(fileSystem.readFileSync(packagePath, 'utf8'));
437
+ } catch (error) {
438
+ throw new Error(`package.json could not be read. fix its json and add a "start" script: ${error.message}`);
439
+ }
440
+ const scripts = packageJson && packageJson.scripts;
441
+ if (!scripts || typeof scripts.start !== 'string' || !scripts.start.trim()) {
442
+ throw new Error('fullstack deploy needs a start script. add "scripts": { "start": "node server.js" } to package.json.');
443
+ }
444
+ const hasBuild = typeof scripts.build === 'string' && scripts.build.trim();
445
+ return { buildCommand: hasBuild ? 'npm install && npm run build' : 'npm install' };
446
+ }
447
+
448
+ function printFullstackPlan(slug, packagePlan, log) {
449
+ const repoName = `atris-app-${slug}`;
450
+ const repoUrl = `https://github.com/${GITHUB_ORG}/${repoName}`;
451
+ const renderName = `atris-app-${slug}`;
452
+ log('\n dry run: fullstack deploy');
453
+ log(` repository ${GITHUB_ORG}/${repoName} (private)`);
454
+ log(` render service ${renderName}`);
455
+ log(` repo ${repoUrl}`);
456
+ log(' branch main');
457
+ log(' runtime node');
458
+ log(' plan starter');
459
+ log(' region oregon');
460
+ log(` build ${packagePlan.buildCommand}`);
461
+ log(' start npm start');
462
+ log(' auto deploy yes');
463
+ log(` render target https://${renderName}.onrender.com`);
464
+ log(` target https://${slug}.atris.ai`);
465
+ log(' no network calls made');
466
+ }
467
+
468
+ function commandAttempt(command, args, cwd, deps = {}) {
469
+ const execute = deps.spawnSync || spawnSync;
470
+ return execute(command, args, {
471
+ cwd,
472
+ encoding: 'utf8',
473
+ stdio: 'pipe',
474
+ timeout: 120000,
475
+ });
476
+ }
477
+
478
+ function commandError(command, args, attempt) {
479
+ const detail = String(attempt.stderr || attempt.stdout || '').trim();
480
+ const cause = attempt.error ? attempt.error.message : `exit ${attempt.status}`;
481
+ return new Error(`${command} ${args.join(' ')} failed (${cause})${detail ? `\n${detail}` : ''}`);
482
+ }
483
+
484
+ function requireCommand(command, args, cwd, deps = {}) {
485
+ const attempt = commandAttempt(command, args, cwd, deps);
486
+ if (attempt.error || attempt.status !== 0) throw commandError(command, args, attempt);
487
+ return attempt;
488
+ }
489
+
490
+ function replaceScratchContents(sourceRoot, scratchRepo, deps = {}) {
491
+ const fileSystem = deps.fs || fs;
492
+ for (const entry of fileSystem.readdirSync(scratchRepo)) {
493
+ if (entry !== '.git') fileSystem.rmSync(path.join(scratchRepo, entry), { recursive: true, force: true });
494
+ }
495
+ for (const entry of fileSystem.readdirSync(sourceRoot)) {
496
+ if (entry === '.git' || entry === 'node_modules') continue;
497
+ fileSystem.cpSync(path.join(sourceRoot, entry), path.join(scratchRepo, entry), {
498
+ recursive: true,
499
+ dereference: false,
500
+ });
501
+ }
502
+ }
503
+
504
+ function publishGithubRepo(sourceRoot, slug, deps = {}) {
505
+ const fileSystem = deps.fs || fs;
506
+ const log = deps.log || console.log;
507
+ const repoName = `atris-app-${slug}`;
508
+ const repoPath = `${GITHUB_ORG}/${repoName}`;
509
+ const repoUrl = `https://github.com/${repoPath}`;
510
+
511
+ requireCommand('gh', ['auth', 'status'], sourceRoot, deps);
512
+ const viewArgs = ['repo', 'view', repoPath, '--json', 'visibility'];
513
+ const view = commandAttempt('gh', viewArgs, sourceRoot, deps);
514
+ if (view.error) throw commandError('gh', viewArgs, view);
515
+ const repoExists = view.status === 0;
516
+ if (!repoExists) {
517
+ requireCommand('gh', ['repo', 'create', repoPath, '--private'], sourceRoot, deps);
518
+ log(` created private repository ${repoPath}`);
519
+ } else {
520
+ let visibility = null;
521
+ try { visibility = JSON.parse(view.stdout).visibility; } catch {}
522
+ if (visibility && visibility !== 'PRIVATE') {
523
+ requireCommand('gh', [
524
+ 'repo', 'edit', repoPath, '--visibility', 'private',
525
+ '--accept-visibility-change-consequences',
526
+ ], sourceRoot, deps);
527
+ }
528
+ log(` reusing private repository ${repoPath}`);
529
+ }
530
+
531
+ const scratchRoot = fileSystem.mkdtempSync(path.join(os.tmpdir(), 'atris-fullstack-'));
532
+ const scratchRepo = path.join(scratchRoot, 'repo');
533
+ fileSystem.mkdirSync(scratchRepo);
534
+ try {
535
+ requireCommand('git', ['init'], scratchRepo, deps);
536
+ requireCommand('git', ['config', 'user.name', 'Atris'], scratchRepo, deps);
537
+ requireCommand('git', ['config', 'user.email', '299057014+atris-builder[bot]@users.noreply.github.com'], scratchRepo, deps);
538
+ requireCommand('git', ['config', 'commit.gpgsign', 'false'], scratchRepo, deps);
539
+ requireCommand('git', ['remote', 'add', 'origin', `${repoUrl}.git`], scratchRepo, deps);
540
+ if (repoExists) {
541
+ const fetched = commandAttempt('git', ['fetch', '--depth=1', 'origin', 'main'], scratchRepo, deps);
542
+ if (fetched.status === 0) {
543
+ requireCommand('git', ['checkout', '-B', 'main', 'FETCH_HEAD'], scratchRepo, deps);
544
+ } else if (!fetched.error && /remote ref main|couldn't find remote ref/i.test(String(fetched.stderr || ''))) {
545
+ requireCommand('git', ['checkout', '-b', 'main'], scratchRepo, deps);
546
+ } else {
547
+ throw commandError('git', ['fetch', '--depth=1', 'origin', 'main'], fetched);
548
+ }
549
+ } else {
550
+ requireCommand('git', ['checkout', '-b', 'main'], scratchRepo, deps);
551
+ }
552
+ replaceScratchContents(sourceRoot, scratchRepo, deps);
553
+ requireCommand('git', ['add', '--all'], scratchRepo, deps);
554
+ const changed = commandAttempt('git', ['diff', '--cached', '--quiet'], scratchRepo, deps);
555
+ if (changed.error || (changed.status !== 0 && changed.status !== 1)) {
556
+ throw commandError('git', ['diff', '--cached', '--quiet'], changed);
557
+ }
558
+ if (changed.status === 1) {
559
+ requireCommand('git', ['commit', '-m', `deploy ${slug}`], scratchRepo, deps);
560
+ }
561
+ requireCommand('git', ['push', '--set-upstream', 'origin', 'main'], scratchRepo, deps);
562
+ } finally {
563
+ fileSystem.rmSync(scratchRoot, { recursive: true, force: true });
564
+ }
565
+ log(` pushed source to ${repoPath}`);
566
+ return repoUrl;
567
+ }
568
+
569
+ function serviceFromPayload(payload) {
570
+ if (!payload || typeof payload !== 'object') return null;
571
+ return payload.service || payload;
572
+ }
573
+
574
+ function deployFromPayload(payload) {
575
+ if (!payload || typeof payload !== 'object') return null;
576
+ return payload.deploy || payload;
577
+ }
578
+
579
+ async function renderRequest(method, url, apiKey, body, deps = {}) {
580
+ const response = await requestJson(method, url, apiKey, body, deps);
581
+ if (response.status < 200 || response.status >= 300) throw requestError(method, url, response);
582
+ return parseResponseData(response);
583
+ }
584
+
585
+ async function ensureRenderService(slug, repoUrl, buildCommand, apiKey, deps = {}) {
586
+ const log = deps.log || console.log;
587
+ const serviceName = `atris-app-${slug}`;
588
+ const listUrl = `${RENDER_API_BASE}/services?name=${encodeURIComponent(serviceName)}&limit=100`;
589
+ const listed = await renderRequest('GET', listUrl, apiKey, undefined, deps);
590
+ const entries = Array.isArray(listed) ? listed : [];
591
+ const existing = entries.map(serviceFromPayload).find((service) => service && service.name === serviceName);
592
+ if (existing) {
593
+ log(` reusing render service ${serviceName}`);
594
+ const deployPayload = await renderRequest(
595
+ 'POST',
596
+ `${RENDER_API_BASE}/services/${encodeURIComponent(existing.id)}/deploys`,
597
+ apiKey,
598
+ undefined,
599
+ deps,
600
+ );
601
+ return { service: existing, deploy: deployFromPayload(deployPayload) };
602
+ }
603
+
604
+ const backendPayload = await renderRequest(
605
+ 'GET',
606
+ `${RENDER_API_BASE}/services/${RENDER_SERVICE_ID}`,
607
+ apiKey,
608
+ undefined,
609
+ deps,
610
+ );
611
+ const backendService = serviceFromPayload(backendPayload);
612
+ if (!backendService || !backendService.ownerId) {
613
+ throw new Error(`render service ${RENDER_SERVICE_ID} did not return a workspace id`);
614
+ }
615
+ const servicePayload = await renderRequest('POST', `${RENDER_API_BASE}/services`, apiKey, {
616
+ type: 'web_service',
617
+ name: serviceName,
618
+ ownerId: backendService.ownerId,
619
+ repo: repoUrl,
620
+ branch: 'main',
621
+ autoDeploy: 'yes',
622
+ serviceDetails: {
623
+ runtime: 'node',
624
+ plan: 'starter',
625
+ region: 'oregon',
626
+ envSpecificDetails: {
627
+ buildCommand,
628
+ startCommand: 'npm start',
629
+ },
630
+ },
631
+ }, deps);
632
+ const created = serviceFromPayload(servicePayload);
633
+ if (!created || !created.id) throw new Error(`render did not return the new service ${serviceName}`);
634
+ log(` created render service ${serviceName}`);
635
+ return { service: created, deploy: null };
636
+ }
637
+
638
+ async function pollRenderDeploy(serviceId, initialDeploy, apiKey, deps = {}) {
639
+ const log = deps.log || console.log;
640
+ const sleep = deps.sleep || ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
641
+ const interval = deps.pollIntervalMs === undefined ? RENDER_POLL_INTERVAL_MS : deps.pollIntervalMs;
642
+ const timeout = deps.pollTimeoutMs === undefined ? RENDER_POLL_TIMEOUT_MS : deps.pollTimeoutMs;
643
+ const maxPolls = Math.max(1, Math.floor(timeout / Math.max(1, interval)) + 1);
644
+ const deployId = initialDeploy && initialDeploy.id;
645
+
646
+ for (let poll = 0; poll < maxPolls; poll += 1) {
647
+ let deploy;
648
+ if (deployId) {
649
+ const payload = await renderRequest(
650
+ 'GET',
651
+ `${RENDER_API_BASE}/services/${encodeURIComponent(serviceId)}/deploys/${encodeURIComponent(deployId)}`,
652
+ apiKey,
653
+ undefined,
654
+ deps,
655
+ );
656
+ deploy = deployFromPayload(payload);
657
+ } else {
658
+ const payload = await renderRequest(
659
+ 'GET',
660
+ `${RENDER_API_BASE}/services/${encodeURIComponent(serviceId)}/deploys?limit=1`,
661
+ apiKey,
662
+ undefined,
663
+ deps,
664
+ );
665
+ const first = Array.isArray(payload) ? payload[0] : null;
666
+ deploy = deployFromPayload(first);
667
+ }
668
+ const status = deploy && deploy.status ? deploy.status : 'waiting';
669
+ log(` render deploy status: ${status}`);
670
+ if (status === 'live') return deploy;
671
+ if (/failed|canceled|deactivated/.test(status)) {
672
+ throw new Error(`render deploy ${status}`);
673
+ }
674
+ if (poll + 1 < maxPolls) await sleep(interval);
675
+ }
676
+ throw new Error('render deploy did not go live within 10 minutes');
677
+ }
678
+
679
+ function proxyTargetFromService(service) {
680
+ const rawUrl = service && ((service.serviceDetails && service.serviceDetails.url) || service.url);
681
+ if (!rawUrl) return null;
682
+ try {
683
+ return new URL(rawUrl).hostname;
684
+ } catch {
685
+ return String(rawUrl).replace(/^https?:\/\//, '').replace(/\/$/, '');
686
+ }
687
+ }
688
+
689
+ async function renderProxyTarget(service, apiKey, deps = {}) {
690
+ let proxyTarget = proxyTargetFromService(service);
691
+ if (!proxyTarget) {
692
+ const payload = await renderRequest(
693
+ 'GET',
694
+ `${RENDER_API_BASE}/services/${encodeURIComponent(service.id)}`,
695
+ apiKey,
696
+ undefined,
697
+ deps,
698
+ );
699
+ proxyTarget = proxyTargetFromService(serviceFromPayload(payload));
700
+ }
701
+ if (!proxyTarget) throw new Error(`render service ${service.name || service.id} did not return its url`);
702
+ if (!/^[a-z0-9-]+\.onrender\.com$/i.test(proxyTarget)) {
703
+ throw new Error(`render service ${service.name || service.id} returned an invalid proxy target`);
704
+ }
705
+ return proxyTarget;
706
+ }
707
+
708
+ async function setProxyTarget(sitesUrl, slug, proxyTarget, token, deps = {}) {
709
+ const log = deps.log || console.log;
710
+ const url = `${sitesUrl}/${slug}`;
711
+ const body = { proxy_target: proxyTarget };
712
+ const response = await requestJson('PATCH', url, token, body, deps);
713
+ if (response.status >= 200 && response.status < 300) {
714
+ log(` wired ${slug}.atris.ai to ${proxyTarget}`);
715
+ return true;
716
+ }
717
+ const detail = errorDetail(response);
718
+ if ((response.status === 400 || response.status === 422) && /proxy_target|unknown|extra|unexpected|not permitted/i.test(detail)) {
719
+ log(` warning: the atris api does not accept proxy_target yet: ${detail}`);
720
+ log(` retry later: PATCH ${url} with ${JSON.stringify(body)}`);
721
+ return false;
722
+ }
723
+ throw requestError('PATCH', url, response);
724
+ }
725
+
726
+ async function runFullstack(root, options, packagePlan, deps = {}) {
727
+ const log = deps.log || console.log;
728
+ const errorLog = deps.error || console.error;
729
+ let credentials;
730
+ let apiKey;
731
+ try {
732
+ const readCredentials = deps.loadCredentials || loadCredentials;
733
+ credentials = readCredentials();
734
+ if (!credentials || !credentials.token) {
735
+ errorLog(' not logged in. run atris login first.');
736
+ return 1;
737
+ }
738
+ apiKey = loadRenderApiKey(deps);
739
+ const repoUrl = publishGithubRepo(root, options.name, { ...deps, log });
740
+ const render = await ensureRenderService(options.name, repoUrl, packagePlan.buildCommand, apiKey, { ...deps, log });
741
+ await pollRenderDeploy(render.service.id, render.deploy, apiKey, { ...deps, log });
742
+ const proxyTarget = await renderProxyTarget(render.service, apiKey, deps);
743
+ const sitesUrl = `${options.apiBase}/api/sites`;
744
+ await createSite(sitesUrl, options.name, false, credentials.token, { ...deps, log });
745
+ const wired = await setProxyTarget(sitesUrl, options.name, proxyTarget, credentials.token, { ...deps, log });
746
+ await registerSubdomain(options.name, { ...deps, log, renderApiKey: apiKey });
747
+ if (wired) log(`\n live at https://${options.name}.atris.ai`);
748
+ else log('\n render infrastructure is ready. retry the proxy patch above to finish the address.');
749
+ return 0;
750
+ } catch (error) {
751
+ const withoutRenderKey = withoutSecret(error.message, apiKey);
752
+ const safeMessage = withoutSecret(withoutRenderKey, credentials && credentials.token);
753
+ errorLog(` fullstack deploy failed: ${safeMessage}`);
754
+ return 1;
755
+ }
756
+ }
757
+
758
+ async function run(argv, deps = {}) {
759
+ const log = deps.log || console.log;
760
+ const errorLog = deps.error || console.error;
761
+ let options;
762
+ try {
763
+ options = parseArgs(argv);
764
+ } catch (error) {
765
+ errorLog(` ${error.message}`);
766
+ return 2;
767
+ }
768
+
769
+ if (options.help) {
770
+ printHelp();
771
+ return 0;
772
+ }
773
+ if (!options.dir) {
774
+ errorLog(' site deploy needs a directory');
775
+ return 2;
776
+ }
777
+ const slugError = validateSlug(options.name);
778
+ if (slugError) {
779
+ errorLog(` ${slugError}`);
780
+ return 2;
781
+ }
782
+
783
+ let apiBase;
784
+ try {
785
+ apiBase = validateApiBase(options.apiBase);
786
+ } catch (error) {
787
+ errorLog(` ${error.message}`);
788
+ return 2;
789
+ }
790
+
791
+ const root = path.resolve(options.dir);
792
+ const fileSystem = deps.fs || fs;
793
+ let stat;
794
+ try {
795
+ stat = fileSystem.statSync(root);
796
+ } catch {
797
+ errorLog(` site directory does not exist: ${root}`);
798
+ return 2;
799
+ }
800
+ if (!stat.isDirectory()) {
801
+ errorLog(` site path is not a directory: ${root}`);
802
+ return 2;
803
+ }
804
+
805
+ if (options.fullstack) {
806
+ let packagePlan;
807
+ try {
808
+ packagePlan = fullstackPackage(root, deps);
809
+ } catch (error) {
810
+ errorLog(` ${error.message}`);
811
+ return 2;
812
+ }
813
+ if (options.dryRun) {
814
+ printFullstackPlan(options.name, packagePlan, log);
815
+ return 0;
816
+ }
817
+ return runFullstack(root, { ...options, apiBase }, packagePlan, deps);
818
+ }
819
+
820
+ let pages;
821
+ try {
822
+ pages = collectPages(root, { ...deps, log });
823
+ } catch (error) {
824
+ errorLog(` could not read site directory: ${error.message}`);
825
+ return 1;
826
+ }
827
+
828
+ const liveUrl = `https://${options.name}.atris.ai`;
829
+ const totalBytes = pages.reduce((sum, page) => sum + page.size, 0);
830
+ if (options.dryRun) {
831
+ log(`\n dry run: ${pages.length} file${pages.length === 1 ? '' : 's'}, ${formatBytes(totalBytes)}`);
832
+ for (const page of pages) log(` would publish ${page.path} (${formatBytes(page.size)})`);
833
+ log(` target ${liveUrl}`);
834
+ log(' no network calls made');
835
+ return 0;
836
+ }
837
+
838
+ const readCredentials = deps.loadCredentials || loadCredentials;
839
+ const credentials = readCredentials();
840
+ if (!credentials || !credentials.token) {
841
+ errorLog(' not logged in. run atris login first.');
842
+ return 1;
843
+ }
844
+
845
+ const sitesUrl = `${apiBase}/api/sites`;
846
+ log(`\n deploying ${pages.length} file${pages.length === 1 ? '' : 's'} to ${liveUrl}`);
847
+ try {
848
+ await createSite(sitesUrl, options.name, options.spa, credentials.token, { ...deps, log });
849
+ await uploadPages(sitesUrl, options.name, pages, credentials.token, { ...deps, log });
850
+ } catch (error) {
851
+ errorLog(` deploy failed: ${error.message}`);
852
+ return 1;
853
+ }
854
+
855
+ await registerSubdomain(options.name, { ...deps, log });
856
+ log(`\n live at ${liveUrl}`);
857
+ return 0;
858
+ }
859
+
860
+ module.exports = {
861
+ BATCH_SIZE,
862
+ MAX_FILE_BYTES,
863
+ MAX_FILES,
864
+ collectPages,
865
+ parseArgs,
866
+ registerSubdomain,
867
+ renderApiKey,
868
+ run,
869
+ validateSlug,
870
+ };