@autoappy/cli 0.1.0-beta.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.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @autoappy/cli
2
+
3
+ Develop and synchronize Auto Appy applications from VS Code.
4
+
5
+ > Beta contract: the CLI is implemented and locally tested, but app source pull/push requires the matching Auto Appy backend source endpoints before public use.
6
+
7
+ ## Start a project
8
+
9
+ ```bash
10
+ mkdir my-autoappy-app
11
+ cd my-autoappy-app
12
+ npx @autoappy/cli init
13
+ npm install
14
+ npm run pull
15
+ npm run dev
16
+ ```
17
+
18
+ The starter keeps credentials in `.env` and non-secret project identity in `autoappy.json`.
19
+
20
+ ## Commands
21
+
22
+ ```text
23
+ autoappy init
24
+ autoappy dev
25
+ autoappy status
26
+ autoappy pull
27
+ autoappy push
28
+ autoappy code pull|push
29
+ autoappy db pull|push
30
+ autoappy api pull|push
31
+ autoappy check
32
+ ```
33
+
34
+ `db push` creates a reviewed backend plan and carries its token internally. It asks for one terminal confirmation; CI may use `--yes` after the Git commit has been reviewed.
35
+
36
+ Database synchronization covers schema only. Customer records are never copied into the project or Git by these commands.
37
+
38
+ ## Required backend contract
39
+
40
+ The current Auto Appy development-kit, schema and managed-API endpoints are used directly. Frontend synchronization additionally requires:
41
+
42
+ ```text
43
+ GET /api/v1/platform/organisations/{workspace}/data/apps/{app}/source
44
+ PATCH /api/v1/platform/organisations/{workspace}/data/apps/{app}/source
45
+ ```
46
+
47
+ The GET response returns `project`, `version`, `source_hash` and `status`. PATCH accepts `project`, `expected_version`, `source_hash` and `request_id`, then returns a versioned deployment receipt.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js';
3
+
4
+ run(process.argv.slice(2)).catch(error => {
5
+ console.error(`Auto Appy: ${error.message}`);
6
+ process.exitCode = 1;
7
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@autoappy/cli",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Develop and synchronize Auto Appy applications from VS Code.",
5
+ "type": "module",
6
+ "bin": {
7
+ "autoappy": "bin/autoappy.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "templates",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test",
17
+ "check": "node --check bin/autoappy.js && node --check src/*.js",
18
+ "prepublishOnly": "npm run check && npm test"
19
+ },
20
+ "engines": {
21
+ "node": ">=22"
22
+ },
23
+ "keywords": [
24
+ "autoappy",
25
+ "cli",
26
+ "low-code",
27
+ "deployment",
28
+ "vscode"
29
+ ],
30
+ "license": "UNLICENSED",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }
package/src/cli.js ADDED
@@ -0,0 +1,107 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { AutoAppyClient } from './client.js';
4
+ import { connectionEnvironment } from './env.js';
5
+ import { hash, readConfig, readLocalProject, readState } from './files.js';
6
+ import { initialize } from './init.js';
7
+ import { pullCode, pullDataDefinitions, pushApis, pushCode, pushSchema } from './sync.js';
8
+
9
+ function help() {
10
+ console.log(`Auto Appy CLI
11
+
12
+ Usage: autoappy <command>
13
+
14
+ init Create a new VS Code project
15
+ dev Start the local application
16
+ status Show local synchronization status
17
+ pull Pull app code, database schema and APIs
18
+ push Push database schema, APIs and app code
19
+ code pull Pull hosted frontend files
20
+ code push Push hosted frontend files
21
+ db pull Pull the current database schema
22
+ db push Review and apply database schema changes
23
+ api pull Pull managed API definitions
24
+ api push Push managed API definitions
25
+ check Validate hosted frontend files
26
+ `);
27
+ }
28
+
29
+ function context(root) {
30
+ const config = readConfig(root);
31
+ const env = connectionEnvironment(root);
32
+ return { config, client: new AutoAppyClient({ ...env, workspaceId: config.workspaceId, appId: config.appId }) };
33
+ }
34
+
35
+ function runLocal(binary, args, root) {
36
+ const command = process.platform === 'win32' ? `${binary}.cmd` : binary;
37
+ const filename = path.join(root, 'node_modules', '.bin', command);
38
+ return new Promise((resolve, reject) => {
39
+ const child = spawn(filename, args, { cwd: root, stdio: 'inherit' });
40
+ child.once('error', () => reject(new Error('Run npm install before starting local development.')));
41
+ child.once('exit', code => code === 0 ? resolve() : reject(new Error(`${binary} exited with status ${code}.`)));
42
+ });
43
+ }
44
+
45
+ export async function run(args, options = {}) {
46
+ const root = path.resolve(options.root || process.cwd());
47
+ const [command = 'help', subcommand] = args;
48
+ const yes = args.includes('--yes');
49
+ if (command === 'help' || command === '--help' || command === '-h') return help();
50
+ if (command === 'init') {
51
+ initialize(root);
52
+ console.log('Auto Appy project created. Fill .env and autoappy.json, run npm install, then npm run pull.');
53
+ return;
54
+ }
55
+ if (command === 'dev') return runLocal('vite', ['app', '--host', '127.0.0.1'], root);
56
+ if (command === 'check') {
57
+ const project = readLocalProject(root);
58
+ console.log(`Validated ${project.files.length} hosted frontend file(s).`);
59
+ return;
60
+ }
61
+ if (command === 'status') {
62
+ const project = readLocalProject(root);
63
+ const state = readState(root);
64
+ const changed = !state.code?.project || hash(project) !== hash(state.code.project);
65
+ console.log(changed ? 'Frontend: local changes' : `Frontend: synchronized at version ${state.code.version}`);
66
+ return;
67
+ }
68
+ const { config, client } = context(root);
69
+ if (command === 'code' && subcommand === 'pull') return console.log(`Pulled ${(await pullCode({ root, client })).files} frontend file(s).`);
70
+ if (command === 'code' && subcommand === 'push') {
71
+ const result = await pushCode({ root, client });
72
+ console.log(result.changed ? `Pushed ${result.files} frontend file(s) as version ${result.version}${result.live ? ' (live)' : ''}.` : 'Frontend is already current.');
73
+ return;
74
+ }
75
+ if (command === 'db' && subcommand === 'pull') {
76
+ const result = await pullDataDefinitions({ root, client, includeApis: false });
77
+ console.log(result.schema ? 'Pulled the database schema.' : 'The database schema is too large for a complete pull.');
78
+ return;
79
+ }
80
+ if (command === 'api' && subcommand === 'pull') {
81
+ const result = await pullDataDefinitions({ root, client, includeSchema: false });
82
+ console.log(`Pulled ${result.apis} API definition(s).`);
83
+ return;
84
+ }
85
+ if (command === 'db' && subcommand === 'push') {
86
+ const result = await pushSchema({ root, client, config, yes });
87
+ console.log(result.applied ? `Applied ${result.changes} database change(s).` : 'Database changes were not applied.');
88
+ return;
89
+ }
90
+ if (command === 'api' && subcommand === 'push') return console.log(`Saved ${(await pushApis({ root, client, config })).saved} API definition(s).`);
91
+ if (command === 'pull') {
92
+ const code = await pullCode({ root, client });
93
+ const definitions = await pullDataDefinitions({ root, client });
94
+ console.log(`Pulled ${code.files} frontend files, the database schema and ${definitions.apis} API definition(s).`);
95
+ return;
96
+ }
97
+ if (command === 'push') {
98
+ const database = await pushSchema({ root, client, config, yes });
99
+ if (!database.applied) return console.log('Push cancelled before any API or frontend changes.');
100
+ const apis = await pushApis({ root, client, config });
101
+ const code = await pushCode({ root, client });
102
+ console.log(`Push complete: ${database.changes} database change(s), ${apis.saved} API definition(s), ${code.files} frontend file(s).`);
103
+ return;
104
+ }
105
+ help();
106
+ throw new Error('Unknown command.');
107
+ }
package/src/client.js ADDED
@@ -0,0 +1,43 @@
1
+ export class AutoAppyClient {
2
+ constructor({ baseUrl, token, workspaceId, appId, fetchImpl = fetch }) {
3
+ this.baseUrl = baseUrl;
4
+ this.token = token;
5
+ this.workspaceId = workspaceId;
6
+ this.appId = appId;
7
+ this.fetch = fetchImpl;
8
+ }
9
+
10
+ path(suffix) {
11
+ return `${this.baseUrl}/api/v1/platform/organisations/${encodeURIComponent(this.workspaceId)}/data/${suffix.replace(/^\//, '')}`;
12
+ }
13
+
14
+ async request(method, suffix, body) {
15
+ let response;
16
+ try {
17
+ response = await this.fetch(this.path(suffix), {
18
+ method,
19
+ headers: { Accept: 'application/json', Authorization: `Bearer ${this.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
20
+ body: body === undefined ? undefined : JSON.stringify(body),
21
+ });
22
+ } catch {
23
+ throw new Error('Could not reach Auto Appy. Check AUTOAPPY_BASE_URL and your connection.');
24
+ }
25
+ let result;
26
+ try { result = await response.json(); } catch { result = null; }
27
+ if (!response.ok) {
28
+ const message = result?.message || result?.errors && Object.values(result.errors).flat()[0] || `Auto Appy request failed with HTTP ${response.status}.`;
29
+ const error = new Error(message);
30
+ error.status = response.status;
31
+ throw error;
32
+ }
33
+ return result?.data ?? result;
34
+ }
35
+
36
+ devkit() { return this.request('GET', `apps/${encodeURIComponent(this.appId)}/devkit`); }
37
+ source() { return this.request('GET', `apps/${encodeURIComponent(this.appId)}/source`); }
38
+ saveSource(payload) { return this.request('PATCH', `apps/${encodeURIComponent(this.appId)}/source`, payload); }
39
+ planSchema(schema) { return this.request('POST', `apps/${encodeURIComponent(this.appId)}/schema/plan`, { schema }); }
40
+ applySchema(token, reviewHash) { return this.request('POST', `apps/${encodeURIComponent(this.appId)}/schema/${encodeURIComponent(token)}/apply`, { review_hash: reviewHash }); }
41
+ createApi(payload) { return this.request('POST', 'api', payload); }
42
+ updateApi(id, payload) { return this.request('PATCH', `api/${encodeURIComponent(id)}`, payload); }
43
+ }
@@ -0,0 +1,9 @@
1
+ export const PROJECT_FORMAT = 'autoappy.project';
2
+ export const PROJECT_VERSION = 1;
3
+ export const VIEW_FORMAT = 'autoappy.view-project';
4
+ export const VIEW_VERSION = 1;
5
+ export const STATE_FORMAT = 'autoappy.sync-state';
6
+ export const STATE_VERSION = 1;
7
+ export const MAX_FILES = 1200;
8
+ export const MAX_SOURCE_BYTES = 4_000_000;
9
+ export const ALLOWED_APP_EXTENSIONS = new Set(['.html', '.css', '.js', '.jsx', '.json', '.svg']);
package/src/env.js ADDED
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ function unquote(value) {
5
+ const trimmed = value.trim();
6
+ if (trimmed.length >= 2 && ((trimmed[0] === '"' && trimmed.at(-1) === '"') || (trimmed[0] === "'" && trimmed.at(-1) === "'"))) {
7
+ return trimmed.slice(1, -1);
8
+ }
9
+ return trimmed;
10
+ }
11
+
12
+ export function loadEnvironment(root) {
13
+ const values = { ...process.env };
14
+ const filename = path.join(root, '.env');
15
+ if (!fs.existsSync(filename)) return values;
16
+ for (const line of fs.readFileSync(filename, 'utf8').split(/\r?\n/)) {
17
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
18
+ if (!match || values[match[1]] !== undefined) continue;
19
+ values[match[1]] = unquote(match[2]);
20
+ }
21
+ return values;
22
+ }
23
+
24
+ export function connectionEnvironment(root) {
25
+ const env = loadEnvironment(root);
26
+ const baseUrl = String(env.AUTOAPPY_BASE_URL || '').replace(/\/+$/, '');
27
+ const token = String(env.AUTOAPPY_APP_TOKEN || '');
28
+ if (!/^https?:\/\//.test(baseUrl)) throw new Error('Set AUTOAPPY_BASE_URL in .env. Use HTTPS outside local development.');
29
+ if (!token.startsWith('aa_app_')) throw new Error('Set AUTOAPPY_APP_TOKEN in .env.');
30
+ return { baseUrl, token };
31
+ }
package/src/files.js ADDED
@@ -0,0 +1,118 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ALLOWED_APP_EXTENSIONS, MAX_FILES, MAX_SOURCE_BYTES, PROJECT_FORMAT, PROJECT_VERSION, STATE_FORMAT, STATE_VERSION, VIEW_FORMAT, VIEW_VERSION } from './constants.js';
5
+
6
+ export const json = value => `${JSON.stringify(value, null, 2)}\n`;
7
+ export const hash = value => crypto.createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
8
+
9
+ export function assertSafeRelative(name) {
10
+ const parts = String(name || '').split('/');
11
+ const internalState = parts[0] === '.autoappy' && parts.length > 1;
12
+ if (!name || path.isAbsolute(name) || name.includes('\\') || parts.some((part, index) => !part || part === '.' || part === '..' || (part.startsWith('.') && !(internalState && index === 0)))) {
13
+ throw new Error(`Unsafe project path: ${name || '(empty)'}`);
14
+ }
15
+ }
16
+
17
+ export function projectFile(root, name) {
18
+ assertSafeRelative(name);
19
+ let current = root;
20
+ for (const part of name.split('/')) {
21
+ current = path.join(current, part);
22
+ if (!fs.existsSync(current)) continue;
23
+ const stat = fs.lstatSync(current);
24
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error(`Links and special files are not supported: ${name}`);
25
+ }
26
+ return current;
27
+ }
28
+
29
+ export function readJson(filename, fallback = null) {
30
+ if (!fs.existsSync(filename)) return fallback;
31
+ return JSON.parse(fs.readFileSync(filename, 'utf8'));
32
+ }
33
+
34
+ export function writeFile(root, name, content) {
35
+ const filename = projectFile(root, name);
36
+ fs.mkdirSync(path.dirname(filename), { recursive: true });
37
+ fs.writeFileSync(filename, content);
38
+ }
39
+
40
+ export function removeFile(root, name) {
41
+ const filename = projectFile(root, name);
42
+ if (fs.existsSync(filename)) fs.unlinkSync(filename);
43
+ }
44
+
45
+ export function readConfig(root) {
46
+ const filename = path.join(root, 'autoappy.json');
47
+ const config = readJson(filename);
48
+ if (!config || config.format !== PROJECT_FORMAT || config.version !== PROJECT_VERSION) throw new Error('Run autoappy init first or repair autoappy.json.');
49
+ if (!/^[a-f0-9-]{36}$/i.test(config.workspaceId || '') || !/^[a-f0-9-]{36}$/i.test(config.appId || '')) {
50
+ throw new Error('Set workspaceId and appId in autoappy.json.');
51
+ }
52
+ if (config.sourceDirectory !== 'app') throw new Error('The first CLI version requires sourceDirectory to be app.');
53
+ return config;
54
+ }
55
+
56
+ export function readState(root) {
57
+ const state = readJson(projectFile(root, '.autoappy/state.json'), { format: STATE_FORMAT, version: STATE_VERSION });
58
+ if (state.format !== STATE_FORMAT || state.version !== STATE_VERSION) throw new Error('Invalid .autoappy/state.json. Move it aside and pull again.');
59
+ return state;
60
+ }
61
+
62
+ export function writeState(root, state) {
63
+ writeFile(root, '.autoappy/state.json', json({ ...state, format: STATE_FORMAT, version: STATE_VERSION }));
64
+ }
65
+
66
+ export function normalizeViewProject(value) {
67
+ const project = typeof value === 'string' ? JSON.parse(value) : value;
68
+ if (!project || project.format !== VIEW_FORMAT || project.version !== VIEW_VERSION || typeof project.entry !== 'string' || !Array.isArray(project.files)) {
69
+ throw new Error('Auto Appy returned an invalid frontend project.');
70
+ }
71
+ if (project.files.length > MAX_FILES) throw new Error(`Keep the frontend within ${MAX_FILES} files.`);
72
+ const seen = new Set();
73
+ let bytes = 0;
74
+ const files = project.files.map(file => {
75
+ if (!file || typeof file.path !== 'string' || typeof file.content !== 'string') throw new Error('Frontend files must contain path and content strings.');
76
+ assertSafeRelative(file.path);
77
+ if (!ALLOWED_APP_EXTENSIONS.has(path.extname(file.path).toLowerCase())) throw new Error(`Unsupported hosted file: ${file.path}`);
78
+ if (seen.has(file.path.toLowerCase())) throw new Error(`Duplicate frontend file: ${file.path}`);
79
+ seen.add(file.path.toLowerCase());
80
+ bytes += Buffer.byteLength(file.content);
81
+ return { path: file.path, content: file.content };
82
+ }).sort((a, b) => a.path.localeCompare(b.path));
83
+ if (bytes > MAX_SOURCE_BYTES) throw new Error('Keep the frontend source within 4 MB.');
84
+ if (!files.some(file => file.path === project.entry)) throw new Error('The frontend entry file is missing.');
85
+ return { format: VIEW_FORMAT, version: VIEW_VERSION, entry: project.entry, files };
86
+ }
87
+
88
+ export function readLocalProject(root) {
89
+ const appRoot = projectFile(root, 'app');
90
+ if (!fs.existsSync(appRoot) || !fs.lstatSync(appRoot).isDirectory()) throw new Error('Missing app/ directory. Run autoappy init.');
91
+ const files = [];
92
+ function walk(directory, prefix = '') {
93
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
94
+ if (entry.name.startsWith('.')) continue;
95
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
96
+ const filename = path.join(directory, entry.name);
97
+ const stat = fs.lstatSync(filename);
98
+ if (stat.isSymbolicLink()) throw new Error(`Links are not supported in app/: ${relative}`);
99
+ if (stat.isDirectory()) walk(filename, relative);
100
+ else if (stat.isFile()) files.push({ path: relative, content: fs.readFileSync(filename, 'utf8') });
101
+ else throw new Error(`Special files are not supported in app/: ${relative}`);
102
+ }
103
+ }
104
+ walk(appRoot);
105
+ return normalizeViewProject({ format: VIEW_FORMAT, version: VIEW_VERSION, entry: 'index.html', files });
106
+ }
107
+
108
+ export function writeLocalProject(root, project) {
109
+ const normalized = normalizeViewProject(project);
110
+ const current = readLocalProject(root);
111
+ const nextNames = new Set(normalized.files.map(file => file.path));
112
+ for (const file of current.files) if (!nextNames.has(file.path)) removeFile(root, `app/${file.path}`);
113
+ for (const file of normalized.files) writeFile(root, `app/${file.path}`, file.content);
114
+ }
115
+
116
+ export function projectMap(project) {
117
+ return Object.fromEntries(normalizeViewProject(project).files.map(file => [file.path, file.content]));
118
+ }
package/src/init.js ADDED
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const templateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../templates/hosted-app');
6
+
7
+ function copyTree(source, destination) {
8
+ if (fs.existsSync(destination)) {
9
+ const stat = fs.lstatSync(destination);
10
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Cannot initialize through a link or file: ${destination}`);
11
+ }
12
+ fs.mkdirSync(destination, { recursive: true });
13
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
14
+ const from = path.join(source, entry.name);
15
+ const to = path.join(destination, entry.name);
16
+ if (fs.existsSync(to) && fs.lstatSync(to).isSymbolicLink()) throw new Error(`Cannot initialize through a link: ${to}`);
17
+ if (entry.isDirectory()) copyTree(from, to);
18
+ else if (entry.isFile()) {
19
+ if (fs.existsSync(to)) continue;
20
+ fs.copyFileSync(from, to);
21
+ }
22
+ }
23
+ }
24
+
25
+ export function initialize(root) {
26
+ copyTree(templateRoot, root);
27
+ const example = path.join(root, '.env.example');
28
+ const env = path.join(root, '.env');
29
+ if (!fs.existsSync(env)) fs.copyFileSync(example, env);
30
+ return { root };
31
+ }
package/src/sync.js ADDED
@@ -0,0 +1,134 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline/promises';
5
+ import { hash, json, normalizeViewProject, projectMap, readJson, readLocalProject, readState, removeFile, writeFile, writeLocalProject, writeState } from './files.js';
6
+
7
+ function mapProject(map, entry = 'index.html') {
8
+ return normalizeViewProject({ format: 'autoappy.view-project', version: 1, entry, files: Object.entries(map).map(([path, content]) => ({ path, content })) });
9
+ }
10
+
11
+ export function mergeProjects({ baseline, local, remote }) {
12
+ const old = baseline ? projectMap(baseline) : {};
13
+ const ours = projectMap(local);
14
+ const theirs = projectMap(remote);
15
+ const merged = {};
16
+ const conflicts = [];
17
+ for (const name of new Set([...Object.keys(old), ...Object.keys(ours), ...Object.keys(theirs)])) {
18
+ const before = old[name] ?? null;
19
+ const here = ours[name] ?? null;
20
+ const there = theirs[name] ?? null;
21
+ let value;
22
+ if (here === there) value = here;
23
+ else if (here === before) value = there;
24
+ else if (there === before) value = here;
25
+ else { conflicts.push({ path: name, baseline: before, local: here, remote: there }); continue; }
26
+ if (value !== null) merged[name] = value;
27
+ }
28
+ return { project: conflicts.length ? null : mapProject(merged, remote.entry), conflicts };
29
+ }
30
+
31
+ export async function pullCode({ root, client }) {
32
+ const remote = await client.source();
33
+ if (!remote?.project || !Number.isInteger(remote.version)) throw new Error('The backend does not yet provide the Auto Appy app-source synchronization contract.');
34
+ const project = normalizeViewProject(remote.project);
35
+ const state = readState(root);
36
+ const local = readLocalProject(root);
37
+ const result = mergeProjects({ baseline: state.code?.project, local, remote: project });
38
+ if (result.conflicts.length) {
39
+ writeFile(root, '.autoappy/conflicts.json', json({ format: 'autoappy.conflicts', version: 1, conflicts: result.conflicts }));
40
+ throw new Error(`${result.conflicts.length} frontend file conflict(s) found. Review .autoappy/conflicts.json; no app files were changed.`);
41
+ }
42
+ writeLocalProject(root, result.project);
43
+ state.code = { version: remote.version, sourceHash: remote.source_hash || hash(project), project };
44
+ writeState(root, state);
45
+ return { files: project.files.length, preserved: hash(result.project) !== hash(project) };
46
+ }
47
+
48
+ export async function pushCode({ root, client }) {
49
+ const local = readLocalProject(root);
50
+ const remote = await client.source();
51
+ if (!remote?.project || !Number.isInteger(remote.version)) throw new Error('The backend does not yet provide the Auto Appy app-source synchronization contract.');
52
+ const state = readState(root);
53
+ const remoteProject = normalizeViewProject(remote.project);
54
+ const baseline = state.code?.project;
55
+ if (!baseline && hash(local) !== hash(remoteProject)) throw new Error('Pull the app before its first push so Auto Appy can protect remote changes.');
56
+ if (baseline && hash(baseline) !== hash(remoteProject) && hash(local) !== hash(remoteProject)) throw new Error('The Auto Appy app changed remotely. Run pull and resolve conflicts before pushing.');
57
+ if (hash(local) === hash(remoteProject)) {
58
+ state.code = { version: remote.version, sourceHash: remote.source_hash || hash(remoteProject), project: remoteProject };
59
+ writeState(root, state);
60
+ return { changed: false, version: remote.version, files: local.files.length };
61
+ }
62
+ const saved = await client.saveSource({ expected_version: remote.version, source_hash: remote.source_hash || hash(remoteProject), request_id: crypto.randomUUID(), project: local });
63
+ if (!saved || !Number.isInteger(saved.version)) throw new Error('Auto Appy returned an incomplete deployment receipt. Check the app before retrying.');
64
+ state.code = { version: saved.version, sourceHash: saved.source_hash || hash(local), project: local, deploymentId: saved.deployment_id || null };
65
+ writeState(root, state);
66
+ return { changed: true, version: saved.version, files: local.files.length, live: saved.status === 'published' };
67
+ }
68
+
69
+ export async function pullDataDefinitions({ root, client, includeSchema = true, includeApis = true }) {
70
+ const kit = await client.devkit();
71
+ if (!kit?.database?.id || !Array.isArray(kit.apis)) throw new Error('Auto Appy returned an incomplete development kit.');
72
+ if (includeSchema && kit.schema) writeFile(root, `models/${kit.database.id}.json`, json(kit.schema));
73
+ const expectedApiFiles = new Set();
74
+ for (const api of includeApis ? kit.apis : []) {
75
+ const definition = { id: api.id, database_id: api.database_id, name: api.name, description: api.description || '', status: api.status, version: api.version, program: api.program || JSON.parse(api.source) };
76
+ const name = `apis/${api.id}.json`;
77
+ expectedApiFiles.add(name);
78
+ writeFile(root, name, json(definition));
79
+ }
80
+ const apiDirectory = path.join(root, 'apis');
81
+ if (includeApis && fs.existsSync(apiDirectory)) for (const name of fs.readdirSync(apiDirectory)) {
82
+ const relative = `apis/${name}`;
83
+ if (/^[a-f0-9-]{36}\.json$/i.test(name) && !expectedApiFiles.has(relative)) removeFile(root, relative);
84
+ }
85
+ const state = readState(root);
86
+ state.devkit = {
87
+ ...(state.devkit || {}), pulledAt: new Date().toISOString(),
88
+ ...(includeSchema ? { databaseVersion: kit.database.version, schemaHash: kit.schema ? hash(kit.schema) : null } : {}),
89
+ ...(includeApis ? { apiVersions: Object.fromEntries(kit.apis.map(api => [api.id, api.version])) } : {}),
90
+ };
91
+ writeState(root, state);
92
+ return { database: kit.database.id, apis: includeApis ? kit.apis.length : 0, schema: includeSchema && Boolean(kit.schema) };
93
+ }
94
+
95
+ async function confirmed(question, yes) {
96
+ if (yes) return true;
97
+ if (!process.stdin.isTTY) throw new Error('Database changes require an interactive confirmation. CI may use --yes after reviewing the commit.');
98
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
99
+ try { return /^(y|yes)$/i.test((await prompt.question(`${question} [y/N] `)).trim()); }
100
+ finally { prompt.close(); }
101
+ }
102
+
103
+ export async function pushSchema({ root, client, config, yes = false }) {
104
+ const filename = path.join(root, 'models', `${config.appId}.json`);
105
+ const schema = readJson(filename);
106
+ if (!schema) throw new Error(`Missing models/${config.appId}.json. Run db pull first.`);
107
+ const plan = await client.planSchema(schema);
108
+ if (!plan?.token || !plan.review_hash || !Array.isArray(plan.changes)) throw new Error('Auto Appy returned an invalid database plan.');
109
+ console.log(json({ changes: plan.changes, warnings: plan.warnings || [] }).trim());
110
+ if (!await confirmed(`Apply ${plan.changes.length} reviewed database change(s)?`, yes)) return { applied: false, changes: plan.changes.length };
111
+ const receipt = await client.applySchema(plan.token, plan.review_hash);
112
+ return { applied: true, changes: plan.changes.length, receipt };
113
+ }
114
+
115
+ export async function pushApis({ root, client, config }) {
116
+ const directory = path.join(root, 'apis');
117
+ if (!fs.existsSync(directory)) return { saved: 0 };
118
+ const files = fs.readdirSync(directory).filter(name => /^[A-Za-z0-9_-]{1,100}\.json$/.test(name)).sort();
119
+ let savedCount = 0;
120
+ for (const name of files) {
121
+ const relative = `apis/${name}`;
122
+ const api = readJson(path.join(directory, name));
123
+ if (!api?.name || !api?.program || !['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(api.program.method)) throw new Error(`Invalid API definition: ${relative}`);
124
+ const payload = { name: api.name, description: api.description || '', source: JSON.stringify(api.program), ...(api.id ? { expected_version: api.version } : { database_id: api.database_id || config.appId }) };
125
+ const saved = api.id ? await client.updateApi(api.id, payload) : await client.createApi(payload);
126
+ if (!saved?.id || !Number.isInteger(saved.version)) throw new Error(`Auto Appy returned an incomplete API receipt for ${relative}. Check before retrying.`);
127
+ const normalized = { ...api, ...saved, program: api.program };
128
+ const destination = `apis/${saved.id}.json`;
129
+ writeFile(root, destination, json(normalized));
130
+ if (relative !== destination) removeFile(root, relative);
131
+ savedCount++;
132
+ }
133
+ return { saved: savedCount };
134
+ }
@@ -0,0 +1,2 @@
1
+ AUTOAPPY_BASE_URL=https://api.autoappy.com
2
+ AUTOAPPY_APP_TOKEN=
@@ -0,0 +1,9 @@
1
+ {
2
+ "version": "2.0.0",
3
+ "tasks": [
4
+ { "label": "Auto Appy: Start", "type": "npm", "script": "dev", "isBackground": true, "problemMatcher": [] },
5
+ { "label": "Auto Appy: Status", "type": "npm", "script": "status", "problemMatcher": [] },
6
+ { "label": "Auto Appy: Pull", "type": "npm", "script": "pull", "problemMatcher": [] },
7
+ { "label": "Auto Appy: Push", "type": "npm", "script": "push", "problemMatcher": [] }
8
+ ]
9
+ }
@@ -0,0 +1,9 @@
1
+ # Auto Appy application
2
+
3
+ 1. Set the workspace and app UUIDs in `autoappy.json`.
4
+ 2. Set `AUTOAPPY_BASE_URL` and `AUTOAPPY_APP_TOKEN` in `.env`.
5
+ 3. Run `npm install` and `npm run pull`.
6
+ 4. Develop with `npm run dev`.
7
+ 5. Review with `npm run status`, then publish with `npm run push`.
8
+
9
+ Database commands synchronize schema definitions, not customer records.
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Auto Appy application</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/App.jsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import './styles.css';
4
+
5
+ function App() {
6
+ return (
7
+ <main className="app-shell">
8
+ <p className="eyebrow">Auto Appy</p>
9
+ <h1>Your application is connected.</h1>
10
+ <p>Edit <code>app/src/App.jsx</code>, then run <code>npm run push</code>.</p>
11
+ </main>
12
+ );
13
+ }
14
+
15
+ createRoot(document.getElementById('root')).render(<App />);
@@ -0,0 +1,36 @@
1
+ :root {
2
+ color: #102f45;
3
+ background: #f4f9fc;
4
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
5
+ }
6
+
7
+ body {
8
+ margin: 0;
9
+ }
10
+
11
+ .app-shell {
12
+ box-sizing: border-box;
13
+ max-width: 760px;
14
+ min-height: 100vh;
15
+ margin: 0 auto;
16
+ padding: 18vh 32px 48px;
17
+ }
18
+
19
+ .eyebrow {
20
+ color: #147db5;
21
+ font-weight: 700;
22
+ letter-spacing: 0.12em;
23
+ text-transform: uppercase;
24
+ }
25
+
26
+ h1 {
27
+ margin: 0 0 16px;
28
+ font-size: clamp(2.25rem, 7vw, 4.75rem);
29
+ line-height: 0.96;
30
+ }
31
+
32
+ code {
33
+ border-radius: 6px;
34
+ background: #e4f0f7;
35
+ padding: 2px 6px;
36
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "format": "autoappy.project",
3
+ "version": 1,
4
+ "workspaceId": "REPLACE_WITH_WORKSPACE_UUID",
5
+ "appId": "REPLACE_WITH_APP_UUID",
6
+ "sourceDirectory": "app"
7
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "autoappy-application",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "autoappy dev",
8
+ "check": "autoappy check",
9
+ "status": "autoappy status",
10
+ "pull": "autoappy pull",
11
+ "push": "autoappy push",
12
+ "code:pull": "autoappy code pull",
13
+ "code:push": "autoappy code push",
14
+ "db:pull": "autoappy db pull",
15
+ "db:push": "autoappy db push",
16
+ "api:pull": "autoappy api pull",
17
+ "api:push": "autoappy api push"
18
+ },
19
+ "dependencies": {
20
+ "react": "18.3.1",
21
+ "react-dom": "18.3.1"
22
+ },
23
+ "devDependencies": {
24
+ "@autoappy/cli": "0.1.0-beta.0",
25
+ "vite": "6.1.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=22"
29
+ }
30
+ }