@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 +47 -0
- package/bin/autoappy.js +7 -0
- package/package.json +34 -0
- package/src/cli.js +107 -0
- package/src/client.js +43 -0
- package/src/constants.js +9 -0
- package/src/env.js +31 -0
- package/src/files.js +118 -0
- package/src/init.js +31 -0
- package/src/sync.js +134 -0
- package/templates/hosted-app/.env.example +2 -0
- package/templates/hosted-app/.vscode/tasks.json +9 -0
- package/templates/hosted-app/README.md +9 -0
- package/templates/hosted-app/app/index.html +12 -0
- package/templates/hosted-app/app/src/App.jsx +15 -0
- package/templates/hosted-app/app/src/styles.css +36 -0
- package/templates/hosted-app/autoappy.json +7 -0
- package/templates/hosted-app/package.json +30 -0
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.
|
package/bin/autoappy.js
ADDED
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
|
+
}
|
package/src/constants.js
ADDED
|
@@ -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,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,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
|
+
}
|