@rentaltide/cli 0.1.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,65 @@
1
+ # @rentaltide/cli
2
+
3
+ Build apps for RentalTide without the deploy-paste-reinstall cycle.
4
+
5
+ ```bash
6
+ npm install -g @rentaltide/cli
7
+
8
+ rentaltide init my-app
9
+ cd my-app && npm install
10
+ rentaltide login
11
+ rentaltide link
12
+ npm run dev # your app on localhost
13
+ rentaltide dev # your sandbox now renders it
14
+ ```
15
+
16
+ ## Why `dev` needs no tunnel
17
+
18
+ Your app is an iframe on a RentalTide page, rendered by **your** browser — so
19
+ `http://localhost:5173` resolves fine. `rentaltide dev` changes your app's embed
20
+ URL to your local one, prints the sandbox links, and changes it back when you
21
+ stop it.
22
+
23
+ It refuses to do this to an **approved** app: repointing one that merchants have
24
+ installed points all of them at your laptop.
25
+
26
+ ## Commands
27
+
28
+ | | |
29
+ | ------------------------------ | ---------------------------------------------------------------------- |
30
+ | `init [dir]` | Scaffold an app that already runs |
31
+ | `login` / `logout` / `whoami` | Partner account, stored in `~/.rentaltide/credentials.json` (mode 600) |
32
+ | `link` | Attach this directory to one of your apps |
33
+ | `dev [--url <url>]` | Point your sandbox at your local app |
34
+ | `push [--yes]` | Apply `rentaltide.app.json` to the app record, with a diff |
35
+ | `pull` | Write the app record back into `rentaltide.app.json` |
36
+ | `webhook trigger <event>` | Send yourself a signed webhook |
37
+ | `open [booking\|portal\|docs]` | Open a sandbox surface |
38
+
39
+ ## `rentaltide.app.json`
40
+
41
+ Scopes and embed locations decide what your app may read and where it renders.
42
+ In a web form they are invisible to review and the only record of a change is
43
+ that it happened. In your repo they diff.
44
+
45
+ ```json
46
+ {
47
+ "appId": "…",
48
+ "name": "Dock Weather",
49
+ "slug": "dock-weather",
50
+ "scopes": ["read:bookings", "read:customers"],
51
+ "embedLocations": ["order-details"],
52
+ "urls": { "embed": "https://dock-weather.example.com/embed" },
53
+ "dev": { "embedUrl": "http://localhost:5173" }
54
+ }
55
+ ```
56
+
57
+ `dev` is local and is never pushed.
58
+
59
+ ## Environment
60
+
61
+ - `RENTALTIDE_API_URL` — defaults to `https://v3.api.rentaltide.com`
62
+ - `RENTALTIDE_APP_URL` — defaults to `https://app.rentaltide.com`
63
+ - `NO_COLOR` — honoured
64
+
65
+ Docs: <https://docs.rentaltide.com/developers/>
package/dist/api.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The partner API, as the CLI sees it.
3
+ *
4
+ * One wrapper so every command reports a failure the same way, and so an
5
+ * expired token says "run rentaltide login" rather than "401".
6
+ */
7
+ import { readCredentials, DEFAULT_API } from './config.js';
8
+ import { fail } from './ui.js';
9
+ export function apiUrl() {
10
+ return process.env.RENTALTIDE_API_URL || readCredentials()?.apiUrl || DEFAULT_API;
11
+ }
12
+ function token() {
13
+ const credentials = readCredentials();
14
+ if (!credentials?.token) {
15
+ fail('You are not signed in.', 'Run `rentaltide login`.');
16
+ }
17
+ return credentials.token;
18
+ }
19
+ export async function request(path, init = {}) {
20
+ const headers = { 'content-type': 'application/json' };
21
+ if (!init.anonymous)
22
+ headers.authorization = `Bearer ${token()}`;
23
+ let response;
24
+ try {
25
+ response = await fetch(`${apiUrl()}${path}`, {
26
+ method: init.method || 'GET',
27
+ headers,
28
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
29
+ });
30
+ }
31
+ catch (err) {
32
+ return fail(`Could not reach ${apiUrl()}.`, err instanceof Error ? err.message : 'Check your connection.');
33
+ }
34
+ const text = await response.text();
35
+ const data = text ? safeJson(text) : null;
36
+ if (response.status === 401) {
37
+ fail('Your session has expired.', 'Run `rentaltide login` again.');
38
+ }
39
+ if (!response.ok) {
40
+ const message = data?.message ||
41
+ data?.error ||
42
+ `${response.status} ${response.statusText}`;
43
+ fail(message);
44
+ }
45
+ return data;
46
+ }
47
+ function safeJson(text) {
48
+ try {
49
+ return JSON.parse(text);
50
+ }
51
+ catch {
52
+ return { message: text.slice(0, 300) };
53
+ }
54
+ }
55
+ /** Project config → the field names the API expects. */
56
+ export function configToAppPayload(config) {
57
+ return {
58
+ appName: config.name,
59
+ appSlug: config.slug,
60
+ developerName: config.developerName,
61
+ description: config.description,
62
+ category: config.category,
63
+ requiredScopes: config.scopes,
64
+ embedLocations: config.embedLocations,
65
+ embedUrl: config.urls?.embed,
66
+ redirectUris: config.urls?.redirect,
67
+ webhookUrl: config.urls?.webhook,
68
+ settingsUrl: config.urls?.settings,
69
+ supportUrl: config.urls?.support,
70
+ privacyPolicyUrl: config.urls?.privacy,
71
+ termsUrl: config.urls?.terms,
72
+ documentationUrl: config.urls?.documentation,
73
+ apiUrl: config.urls?.api,
74
+ };
75
+ }
76
+ /** The API's shape → project config, for `pull`. */
77
+ export function appToConfig(app, existing) {
78
+ return {
79
+ appId: app.appId,
80
+ name: app.appName,
81
+ slug: app.appSlug,
82
+ developerName: app.developerName,
83
+ description: app.description,
84
+ category: app.category,
85
+ scopes: app.requiredScopes || [],
86
+ embedLocations: app.embedLocations || [],
87
+ urls: {
88
+ embed: app.embedUrl,
89
+ redirect: app.redirectUris,
90
+ webhook: app.webhookUrl,
91
+ settings: app.settingsUrl,
92
+ support: app.supportUrl,
93
+ privacy: app.privacyPolicyUrl,
94
+ terms: app.termsUrl,
95
+ documentation: app.documentationUrl,
96
+ api: app.apiUrl,
97
+ },
98
+ // `dev` is local and is never pushed, so a pull must not erase it.
99
+ ...(existing?.dev ? { dev: existing.dev } : {}),
100
+ };
101
+ }
@@ -0,0 +1,37 @@
1
+ /** login / logout / whoami. */
2
+ import { apiUrl, request } from '../api.js';
3
+ import { clearCredentials, readCredentials, writeCredentials } from '../config.js';
4
+ import { ask, bold, dim, ok, say } from '../ui.js';
5
+ export async function login() {
6
+ say(`Sign in to ${bold('partners.rentaltide.com')}`);
7
+ const email = await ask('Email:');
8
+ // Masked, and never written anywhere: the token it returns is what we keep.
9
+ const password = await ask('Password:', { mask: true });
10
+ const result = await request('/partners/login', {
11
+ method: 'POST',
12
+ body: { email, password },
13
+ anonymous: true,
14
+ });
15
+ writeCredentials({
16
+ token: result.token,
17
+ email: result.partner?.email || email,
18
+ partnerId: result.partner?.id,
19
+ apiUrl: apiUrl(),
20
+ savedAt: new Date().toISOString(),
21
+ });
22
+ ok(`Signed in as ${result.partner?.name || email}`);
23
+ say(dim('Token saved to ~/.rentaltide/credentials.json (readable only by you).'));
24
+ }
25
+ export async function logout() {
26
+ clearCredentials();
27
+ ok('Signed out.');
28
+ }
29
+ export async function whoami() {
30
+ const credentials = readCredentials();
31
+ if (!credentials) {
32
+ say('Not signed in. Run `rentaltide login`.');
33
+ return;
34
+ }
35
+ say(`${bold(credentials.email)}`);
36
+ say(dim(`partner ${credentials.partnerId || 'unknown'} · ${credentials.apiUrl}`));
37
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Point your sandbox at the app running on your laptop.
3
+ *
4
+ * The embed is an iframe rendered in YOUR browser, so a `http://localhost` URL
5
+ * resolves perfectly well — there is no tunnel here and none is needed. What
6
+ * was needed was somebody to change the app's embed URL and change it back,
7
+ * which is why this exists: before it, seeing a code change meant deploying
8
+ * somewhere public, pasting the URL into a form, and reinstalling.
9
+ *
10
+ * Two safety rules:
11
+ *
12
+ * - It refuses on an APPROVED app. Repointing one of those at a laptop points
13
+ * every merchant who installed it at a laptop.
14
+ * - It restores the previous embed URL on exit, including Ctrl-C, and says so
15
+ * if it could not.
16
+ */
17
+ import { request } from '../api.js';
18
+ import { requireAppConfig } from '../config.js';
19
+ import { bold, cyan, dim, fail, ok, say, step, warn, yellow } from '../ui.js';
20
+ const DEFAULT_DEV_URL = 'http://localhost:5173';
21
+ export async function dev(args) {
22
+ const config = requireAppConfig();
23
+ if (!config.appId)
24
+ fail('This project is not linked yet.', 'Run `rentaltide link`.');
25
+ const flagIndex = args.findIndex((a) => a === '--url');
26
+ const devUrl = (flagIndex >= 0 ? args[flagIndex + 1] : undefined) || config.dev?.embedUrl || DEFAULT_DEV_URL;
27
+ const app = await request(`/developer/apps/${config.appId}`);
28
+ if (app.status === 'approved') {
29
+ fail(`${app.appName} is approved and installed by real merchants.`, 'Repointing its embed URL would point all of them at your laptop. Work on a draft copy instead.');
30
+ }
31
+ const previousEmbedUrl = app.embedUrl || '';
32
+ // A sandbox to install into. Both calls are idempotent.
33
+ step('Preparing your sandbox…');
34
+ const sandboxResponse = await request('/developer/sandbox', {
35
+ method: 'POST',
36
+ });
37
+ const sandbox = sandboxResponse.sandbox;
38
+ if (!sandbox)
39
+ fail('Could not create your sandbox.');
40
+ await request(`/developer/apps/${config.appId}/sandbox-install`, { method: 'POST' }).catch(() => undefined);
41
+ step(`Pointing ${app.appName} at ${bold(devUrl)}`);
42
+ await request(`/developer/apps/${config.appId}`, {
43
+ method: 'PUT',
44
+ body: { embedUrl: devUrl },
45
+ });
46
+ let restored = false;
47
+ const restore = async () => {
48
+ if (restored)
49
+ return;
50
+ restored = true;
51
+ try {
52
+ await request(`/developer/apps/${config.appId}`, {
53
+ method: 'PUT',
54
+ body: { embedUrl: previousEmbedUrl },
55
+ });
56
+ say(`\n${dim(`Restored embed URL to ${previousEmbedUrl || '(empty)'}.`)}`);
57
+ }
58
+ catch {
59
+ // Said loudly: an app left pointing at a laptop renders nothing for
60
+ // anyone else, and the cause is invisible from the portal.
61
+ warn(`Could not restore the embed URL. Set it back to ${previousEmbedUrl || '(empty)'} in the portal.`);
62
+ }
63
+ };
64
+ process.on('SIGINT', () => void restore().then(() => process.exit(0)));
65
+ process.on('SIGTERM', () => void restore().then(() => process.exit(0)));
66
+ const appUrl = (process.env.RENTALTIDE_APP_URL || 'https://app.rentaltide.com').replace(/\/$/, '');
67
+ say();
68
+ ok(`${app.appName} is live in your sandbox.`);
69
+ say();
70
+ say(` ${bold('Console')} ${cyan(`${appUrl}`)}`);
71
+ say(` ${bold('Booking page')} ${cyan(`${appUrl}/booking/customerId/${sandbox.customerId}`)}`);
72
+ say(` ${bold('Embedding')} ${devUrl}`);
73
+ say(` ${bold('Locations')} ${config.embedLocations.join(', ') || dim('(none declared)')}`);
74
+ say();
75
+ say(dim('Open the console from the partner portal → App Sandbox → Open sandbox console.'));
76
+ say(dim('Edit your app and refresh the page. No redeploy, no reinstall.'));
77
+ say();
78
+ say(yellow('Leave this running.') + dim(' Ctrl-C restores the embed URL.'));
79
+ // Nothing to poll: the page reloads from the developer's own dev server.
80
+ // This process exists to hold the restore handler.
81
+ await new Promise(() => { });
82
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Scaffold an app that already runs.
3
+ *
4
+ * The template is deliberately small — one page, the SDK wired to the host,
5
+ * a live context readout and one API call — because the thing a developer
6
+ * needs on minute one is proof that the handshake works, not a framework.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { writeAppConfig } from '../config.js';
12
+ import { ask, bold, dim, fail, ok, say } from '../ui.js';
13
+ const here = path.dirname(fileURLToPath(import.meta.url));
14
+ function templateDir() {
15
+ // dist/commands/init.js → package root → templates
16
+ const candidates = [
17
+ path.resolve(here, '../../templates/starter'),
18
+ path.resolve(here, '../templates/starter'),
19
+ ];
20
+ for (const dir of candidates)
21
+ if (fs.existsSync(dir))
22
+ return dir;
23
+ return fail('The starter template is missing from this install of the CLI.');
24
+ }
25
+ function copyTree(from, to, replace) {
26
+ fs.mkdirSync(to, { recursive: true });
27
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
28
+ const source = path.join(from, entry.name);
29
+ // `_gitignore` ships under a name npm will not strip from the package.
30
+ const target = path.join(to, entry.name === '_gitignore' ? '.gitignore' : entry.name);
31
+ if (entry.isDirectory()) {
32
+ copyTree(source, target, replace);
33
+ continue;
34
+ }
35
+ const body = fs.readFileSync(source, 'utf8');
36
+ fs.writeFileSync(target, replace(body));
37
+ }
38
+ }
39
+ export async function init(args) {
40
+ const target = args.find((a) => !a.startsWith('-'));
41
+ const name = (await ask(`App name: ${dim('(e.g. Dock Weather)')}`)) || 'My RentalTide App';
42
+ const slug = (await ask(`Slug: ${dim(slugify(name))}`)) || slugify(name);
43
+ const dir = path.resolve(process.cwd(), target || slug);
44
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0) {
45
+ fail(`${dir} already exists and is not empty.`);
46
+ }
47
+ copyTree(templateDir(), dir, (body) => body.replaceAll('__APP_NAME__', name).replaceAll('__APP_SLUG__', slug));
48
+ const config = {
49
+ name,
50
+ slug,
51
+ description: '',
52
+ category: 'Operations & Logistics',
53
+ scopes: ['read:bookings', 'read:customers'],
54
+ embedLocations: ['order-details'],
55
+ urls: { embed: 'https://example.com/embed' },
56
+ dev: { embedUrl: 'http://localhost:5173' },
57
+ };
58
+ writeAppConfig(config, dir);
59
+ ok(`Created ${bold(path.relative(process.cwd(), dir) || '.')}`);
60
+ say();
61
+ say('Next:');
62
+ say(` ${bold(`cd ${path.relative(process.cwd(), dir) || '.'}`)}`);
63
+ say(` ${bold('npm install')}`);
64
+ say(` ${bold('rentaltide login')} ${dim('once per machine')}`);
65
+ say(` ${bold('rentaltide link')} ${dim('pick the app this belongs to')}`);
66
+ say(` ${bold('npm run dev')} ${dim('in one terminal')}`);
67
+ say(` ${bold('rentaltide dev')} ${dim('in another — points your sandbox at it')}`);
68
+ }
69
+ function slugify(value) {
70
+ return value
71
+ .toLowerCase()
72
+ .replace(/[^a-z0-9]+/g, '-')
73
+ .replace(/^-|-$/g, '')
74
+ .slice(0, 60);
75
+ }
@@ -0,0 +1,33 @@
1
+ /** Attach this directory to one of your apps, and pull its config down. */
2
+ import { appToConfig, request } from '../api.js';
3
+ import { readAppConfig, requireAppConfig, writeAppConfig, CONFIG_FILENAME } from '../config.js';
4
+ import { ask, bold, dim, fail, ok, say } from '../ui.js';
5
+ async function listApps() {
6
+ const data = await request('/developer/apps');
7
+ return data.apps || [];
8
+ }
9
+ export async function link() {
10
+ const apps = await listApps();
11
+ if (apps.length === 0) {
12
+ fail('You have no apps yet.', 'Create one at partners.rentaltide.com → Developer → Apps.');
13
+ }
14
+ say('Your apps:');
15
+ apps.forEach((app, index) => {
16
+ say(` ${bold(String(index + 1))}. ${app.appName} ${dim(`(${app.appSlug} · ${app.status})`)}`);
17
+ });
18
+ const answer = await ask(`Which one? ${dim('1-' + apps.length)}`);
19
+ const chosen = apps[Number(answer) - 1];
20
+ if (!chosen)
21
+ fail('That is not one of the options.');
22
+ const existing = readAppConfig();
23
+ writeAppConfig(appToConfig(chosen, existing));
24
+ ok(`Linked to ${chosen.appName}. Wrote ${CONFIG_FILENAME}.`);
25
+ }
26
+ export async function pull() {
27
+ const config = requireAppConfig();
28
+ if (!config.appId)
29
+ fail('This project is not linked yet.', 'Run `rentaltide link`.');
30
+ const app = await request(`/developer/apps/${config.appId}`);
31
+ writeAppConfig(appToConfig(app, config));
32
+ ok(`Pulled ${app.appName} into ${CONFIG_FILENAME}.`);
33
+ }
@@ -0,0 +1,28 @@
1
+ /** Open the sandbox surfaces without hunting for a URL. */
2
+ import { spawn } from 'node:child_process';
3
+ import { request } from '../api.js';
4
+ import { bold, dim, fail, say } from '../ui.js';
5
+ function openUrl(url) {
6
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
7
+ spawn(command, [url], { stdio: 'ignore', detached: true }).unref();
8
+ }
9
+ export async function open(args) {
10
+ const what = args[0] || 'booking';
11
+ const appUrl = (process.env.RENTALTIDE_APP_URL || 'https://app.rentaltide.com').replace(/\/$/, '');
12
+ if (what === 'portal') {
13
+ openUrl('https://partners.rentaltide.com/dashboard/developer');
14
+ return;
15
+ }
16
+ if (what === 'docs') {
17
+ openUrl('https://docs.rentaltide.com/developers/');
18
+ return;
19
+ }
20
+ const { sandbox } = await request('/developer/sandbox');
21
+ if (!sandbox) {
22
+ fail('You have no sandbox yet.', 'Run `rentaltide dev`, or create one in the portal.');
23
+ }
24
+ const url = `${appUrl}/booking/customerId/${sandbox.customerId}`;
25
+ say(`${bold('Opening')} ${url}`);
26
+ say(dim('The console needs a session — open it from the portal → App Sandbox.'));
27
+ openUrl(url);
28
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Apply the project's config to the app record.
3
+ *
4
+ * Shows the diff and asks first. Scopes and embed locations decide what an app
5
+ * may read and where it renders; applying a change to them silently because a
6
+ * file moved is not a thing a tool should do.
7
+ */
8
+ import { appToConfig, configToAppPayload, request } from '../api.js';
9
+ import { requireAppConfig } from '../config.js';
10
+ import { bold, confirm, dim, fail, green, ok, red, say } from '../ui.js';
11
+ function flatten(value) {
12
+ if (Array.isArray(value))
13
+ return value.length ? value.join(', ') : '(none)';
14
+ if (value === undefined || value === null || value === '')
15
+ return '(none)';
16
+ return String(value);
17
+ }
18
+ export async function push(args) {
19
+ const config = requireAppConfig();
20
+ if (!config.appId)
21
+ fail('This project is not linked yet.', 'Run `rentaltide link`.');
22
+ const current = await request(`/developer/apps/${config.appId}`);
23
+ const before = appToConfig(current);
24
+ const fields = [
25
+ ['name', before.name, config.name],
26
+ ['description', before.description, config.description],
27
+ ['category', before.category, config.category],
28
+ ['scopes', before.scopes, config.scopes],
29
+ ['embedLocations', before.embedLocations, config.embedLocations],
30
+ ['urls.embed', before.urls?.embed, config.urls?.embed],
31
+ ['urls.webhook', before.urls?.webhook, config.urls?.webhook],
32
+ ['urls.settings', before.urls?.settings, config.urls?.settings],
33
+ ['urls.support', before.urls?.support, config.urls?.support],
34
+ ['urls.privacy', before.urls?.privacy, config.urls?.privacy],
35
+ ['urls.terms', before.urls?.terms, config.urls?.terms],
36
+ ['urls.redirect', before.urls?.redirect, config.urls?.redirect],
37
+ ];
38
+ const changed = fields.filter(([, a, b]) => flatten(a) !== flatten(b));
39
+ if (changed.length === 0) {
40
+ ok('Nothing to push — the app already matches this project.');
41
+ return;
42
+ }
43
+ say(`${bold('Changes to')} ${current.appName}:`);
44
+ for (const [field, a, b] of changed) {
45
+ say(` ${field}`);
46
+ say(` ${red('-')} ${dim(flatten(a))}`);
47
+ say(` ${green('+')} ${flatten(b)}`);
48
+ }
49
+ say();
50
+ if (!args.includes('--yes') && !(await confirm('Apply?'))) {
51
+ say('Nothing pushed.');
52
+ return;
53
+ }
54
+ await request(`/developer/apps/${config.appId}`, {
55
+ method: 'PUT',
56
+ body: configToAppPayload(config),
57
+ });
58
+ ok('Pushed.');
59
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Send yourself a signed webhook.
3
+ *
4
+ * Verifying a signature is the one part of a webhook integration that cannot
5
+ * be checked by reading the docs, and the only way to exercise it used to be
6
+ * to make a real booking happen.
7
+ */
8
+ import { request } from '../api.js';
9
+ import { requireAppConfig } from '../config.js';
10
+ import { bold, dim, fail, ok, say } from '../ui.js';
11
+ export async function webhook(args) {
12
+ const [sub, event] = args;
13
+ if (sub !== 'trigger' || !event) {
14
+ say(`Usage: ${bold('rentaltide webhook trigger <event>')}`);
15
+ say(dim(' e.g. rentaltide webhook trigger booking.created'));
16
+ return;
17
+ }
18
+ const config = requireAppConfig();
19
+ if (!config.appId)
20
+ fail('This project is not linked yet.', 'Run `rentaltide link`.');
21
+ if (!config.urls?.webhook) {
22
+ fail('No webhook URL in this project.', 'Add urls.webhook to rentaltide.app.json, then `rentaltide push`.');
23
+ }
24
+ const result = await request(`/developer/apps/${config.appId}/webhooks/test`, { method: 'POST', body: { event } });
25
+ if (result.delivered === false) {
26
+ fail(`Your endpoint did not accept it${result.status ? ` (${result.status})` : ''}.`, result.error);
27
+ }
28
+ ok(`Sent ${event} to ${config.urls.webhook}${result.status ? ` — ${result.status}` : ''}`);
29
+ }
package/dist/config.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Where the CLI keeps its two kinds of state.
3
+ *
4
+ * Credentials are per MACHINE (`~/.rentaltide/credentials.json`, mode 600) and
5
+ * never in the project — a token committed to a repo is a partner account
6
+ * handed to whoever clones it.
7
+ *
8
+ * App config is per PROJECT (`rentaltide.app.json`) and belongs in the repo.
9
+ * Scopes and embed locations decide what an app may read and where it renders;
10
+ * typed into a web form they are invisible to review, and the only record of a
11
+ * change is that it happened. In the repo they diff.
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ export const CONFIG_FILENAME = 'rentaltide.app.json';
17
+ export const DEFAULT_API = 'https://v3.api.rentaltide.com';
18
+ const CREDENTIALS_DIR = path.join(os.homedir(), '.rentaltide');
19
+ const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
20
+ export function readCredentials() {
21
+ try {
22
+ const raw = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
23
+ const parsed = JSON.parse(raw);
24
+ return parsed?.token ? parsed : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ export function writeCredentials(credentials) {
31
+ fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
32
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 });
33
+ }
34
+ export function clearCredentials() {
35
+ try {
36
+ fs.unlinkSync(CREDENTIALS_FILE);
37
+ }
38
+ catch {
39
+ /* already gone */
40
+ }
41
+ }
42
+ export function configPath(cwd = process.cwd()) {
43
+ return path.join(cwd, CONFIG_FILENAME);
44
+ }
45
+ export function readAppConfig(cwd = process.cwd()) {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(configPath(cwd), 'utf8'));
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ export function writeAppConfig(config, cwd = process.cwd()) {
54
+ fs.writeFileSync(configPath(cwd), `${JSON.stringify(config, null, 2)}\n`);
55
+ }
56
+ /** The config, or a message telling them exactly what to run. */
57
+ export function requireAppConfig(cwd = process.cwd()) {
58
+ const config = readAppConfig(cwd);
59
+ if (!config) {
60
+ throw new Error(`No ${CONFIG_FILENAME} here. Run \`rentaltide init\` to start an app, or \`rentaltide link\` in an existing one.`);
61
+ }
62
+ return config;
63
+ }
package/dist/index.js ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The RentalTide app CLI.
4
+ *
5
+ * It exists for one reason: before it, seeing a code change in a real
6
+ * RentalTide meant deploying the app somewhere public, pasting the URL into a
7
+ * web form, and reinstalling. Every change. `rentaltide dev` makes it a file
8
+ * save.
9
+ */
10
+ import { login, logout, whoami } from './commands/auth.js';
11
+ import { link, pull } from './commands/link.js';
12
+ import { push } from './commands/push.js';
13
+ import { dev } from './commands/dev.js';
14
+ import { init } from './commands/init.js';
15
+ import { webhook } from './commands/webhook.js';
16
+ import { open } from './commands/open.js';
17
+ import { bold, dim, say } from './ui.js';
18
+ const VERSION = '0.1.0';
19
+ function usage() {
20
+ say(`${bold('rentaltide')} ${dim(VERSION)} — build apps for RentalTide`);
21
+ say();
22
+ say(bold(' Getting started'));
23
+ say(` init [dir] scaffold an app that already runs`);
24
+ say(` login sign in to your partner account`);
25
+ say(` link attach this directory to one of your apps`);
26
+ say();
27
+ say(bold(' Every day'));
28
+ say(` dev [--url <url>] point your sandbox at your local app`);
29
+ say(` push [--yes] apply rentaltide.app.json to the app record`);
30
+ say(` pull write the app record into rentaltide.app.json`);
31
+ say(` webhook trigger <event> send yourself a signed webhook`);
32
+ say(` open [booking|portal|docs]`);
33
+ say();
34
+ say(bold(' Other'));
35
+ say(` whoami who you are signed in as`);
36
+ say(` logout`);
37
+ say();
38
+ say(dim(' Docs: https://docs.rentaltide.com/developers/cli/'));
39
+ }
40
+ async function main() {
41
+ const [command, ...args] = process.argv.slice(2);
42
+ switch (command) {
43
+ case 'init':
44
+ return init(args);
45
+ case 'login':
46
+ return login();
47
+ case 'logout':
48
+ return logout();
49
+ case 'whoami':
50
+ return whoami();
51
+ case 'link':
52
+ return link();
53
+ case 'pull':
54
+ return pull();
55
+ case 'push':
56
+ return push(args);
57
+ case 'dev':
58
+ return dev(args);
59
+ case 'webhook':
60
+ return webhook(args);
61
+ case 'open':
62
+ return open(args);
63
+ case '--version':
64
+ case '-v':
65
+ say(VERSION);
66
+ return;
67
+ case undefined:
68
+ case 'help':
69
+ case '--help':
70
+ case '-h':
71
+ return usage();
72
+ default:
73
+ say(`Unknown command: ${command}`);
74
+ say();
75
+ return usage();
76
+ }
77
+ }
78
+ main().catch((err) => {
79
+ // Commands report their own failures through `fail()`; anything reaching
80
+ // here is a bug, and a stack is more use than a friendly sentence.
81
+ process.stderr.write(`${err instanceof Error ? err.stack || err.message : String(err)}\n`);
82
+ process.exit(1);
83
+ });
package/dist/ui.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Terminal output and prompts.
3
+ *
4
+ * No dependencies: colour is four escape codes and a prompt is one readline.
5
+ * A CLI that pulls a tree of packages to print in green is a CLI that breaks
6
+ * when one of them is yanked.
7
+ */
8
+ import readline from 'node:readline';
9
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
10
+ const wrap = (code) => (text) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
11
+ export const bold = wrap('1');
12
+ export const dim = wrap('2');
13
+ export const green = wrap('32');
14
+ export const yellow = wrap('33');
15
+ export const red = wrap('31');
16
+ export const cyan = wrap('36');
17
+ export const say = (message = '') => process.stdout.write(`${message}\n`);
18
+ export const ok = (message) => say(`${green('✓')} ${message}`);
19
+ export const warn = (message) => say(`${yellow('!')} ${message}`);
20
+ export const step = (message) => say(`${cyan('→')} ${message}`);
21
+ /** Errors explain what went wrong and what to do about it, then exit 1. */
22
+ export function fail(message, hint) {
23
+ process.stderr.write(`${red('✗')} ${message}\n`);
24
+ if (hint)
25
+ process.stderr.write(` ${dim(hint)}\n`);
26
+ process.exit(1);
27
+ }
28
+ export async function ask(question, opts = {}) {
29
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
30
+ /**
31
+ * Resolve on EOF as well as on an answer.
32
+ *
33
+ * With piped stdin (a script, CI) the input ends and `question` never calls
34
+ * back. The promise then stays pending, the event loop empties, and Node
35
+ * exits 0 having done nothing — a command that silently succeeds at nothing
36
+ * is worse than one that fails.
37
+ */
38
+ const answered = (prompt) => new Promise((resolve) => {
39
+ rl.on('close', () => resolve(''));
40
+ rl.question(prompt, resolve);
41
+ });
42
+ if (!opts.mask) {
43
+ const answer = await answered(`${question} `);
44
+ rl.close();
45
+ return answer.trim();
46
+ }
47
+ // Masked input: readline echoes, so intercept the output stream for the
48
+ // duration. A password on screen is a password in a screen share.
49
+ const output = rl;
50
+ const original = output._writeToOutput.bind(output);
51
+ output._writeToOutput = (chunk) => {
52
+ if (chunk.includes(question))
53
+ return original(chunk);
54
+ return original('');
55
+ };
56
+ const answer = await answered(`${question} `);
57
+ output._writeToOutput = original;
58
+ rl.close();
59
+ say();
60
+ return answer.trim();
61
+ }
62
+ export async function confirm(question) {
63
+ const answer = await ask(`${question} ${dim('(y/N)')}`);
64
+ return /^y(es)?$/i.test(answer);
65
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@rentaltide/cli",
3
+ "version": "0.1.0",
4
+ "description": "Build RentalTide apps: scaffold, run against your sandbox, push config, trigger webhooks.",
5
+ "license": "MIT",
6
+ "author": "RentalTide Inc.",
7
+ "homepage": "https://docs.rentaltide.com/developers/cli/",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/RentalTide/RentalTide.git",
11
+ "directory": "packages/RentalTide-App-CLI"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/RentalTide/RentalTide/issues"
15
+ },
16
+ "type": "module",
17
+ "bin": {
18
+ "rentaltide": "./dist/index.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "templates",
23
+ "README.md"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json && node scripts/fix-esm-extensions.mjs && chmod +x dist/index.js",
30
+ "typecheck": "tsc -p tsconfig.json --noEmit",
31
+ "prepublishOnly": "npm run build"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "keywords": [
37
+ "rentaltide",
38
+ "cli",
39
+ "apps",
40
+ "developer"
41
+ ],
42
+ "devDependencies": {
43
+ "typescript": "^5.6.0",
44
+ "@types/node": "^22.0.0"
45
+ }
46
+ }
@@ -0,0 +1,21 @@
1
+ # **APP_NAME**
2
+
3
+ A RentalTide embedded app.
4
+
5
+ ```bash
6
+ npm install
7
+ npm run dev # your app on http://localhost:5173
8
+
9
+ rentaltide login # once per machine
10
+ rentaltide link # attach this directory to your app
11
+ rentaltide dev # point your sandbox at localhost, and print where to look
12
+ ```
13
+
14
+ Then open your sandbox and refresh after each change. No redeploy, no reinstall.
15
+
16
+ - `rentaltide.app.json` — name, scopes, embed locations, URLs. `rentaltide push`
17
+ applies it; `rentaltide pull` brings the record back down.
18
+ - `src/main.js` — the app. It reads context from the host and makes one
19
+ scope-checked API call.
20
+
21
+ Docs: https://docs.rentaltide.com/developers/
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ dist
3
+ .DS_Store
@@ -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" />
6
+ <title>__APP_NAME__</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.js"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "__APP_SLUG__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite --port 5173",
7
+ "build": "vite build",
8
+ "preview": "vite preview --port 5173"
9
+ },
10
+ "dependencies": {
11
+ "@rentaltide/app-sdk": "^0.1.1"
12
+ },
13
+ "devDependencies": {
14
+ "vite": "^6.0.0"
15
+ }
16
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * __APP_NAME__ — a RentalTide embedded app.
3
+ *
4
+ * This runs inside an iframe on a RentalTide page. The SDK is how you talk to
5
+ * the page around you: it hands you the account, the location, the record the
6
+ * merchant is looking at, and the theme they are using, and it makes API calls
7
+ * on your behalf — scope-checked by the host, so your app never holds a
8
+ * credential.
9
+ */
10
+
11
+ import { createApp } from '@rentaltide/app-sdk';
12
+
13
+ const root = document.getElementById('root');
14
+
15
+ async function start() {
16
+ // Resolves once the host has answered the handshake. If this hangs, the page
17
+ // is not embedding you — open it through RentalTide, not directly.
18
+ const app = await createApp();
19
+ const context = app.context;
20
+
21
+ render(context, null);
22
+
23
+ // Bookings the merchant can see. Requires the read:bookings scope, which is
24
+ // declared in rentaltide.app.json and granted at install.
25
+ try {
26
+ const bookings = await app.api.get('/bookings?limit=3');
27
+ render(context, bookings);
28
+ } catch (err) {
29
+ render(context, { error: String(err) });
30
+ }
31
+
32
+ // The merchant may switch location or open a different record without
33
+ // reloading you.
34
+ app.onContextChange((next) => render(next, null));
35
+ }
36
+
37
+ function render(context, data) {
38
+ const theme = context.theme || {};
39
+ document.body.style.margin = '0';
40
+ document.body.style.background = theme.background || '#fff';
41
+ document.body.style.color = theme.text || '#111';
42
+ document.body.style.fontFamily = theme.fontFamily || 'system-ui, sans-serif';
43
+
44
+ root.innerHTML = `
45
+ <div style="padding:16px">
46
+ <h1 style="font-size:15px;margin:0 0 12px">__APP_NAME__</h1>
47
+ <dl style="display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:13px;margin:0">
48
+ <dt style="opacity:.6">Account</dt><dd style="margin:0">${context.account?.businessName ?? '—'}</dd>
49
+ <dt style="opacity:.6">Location</dt><dd style="margin:0">${context.location?.name ?? '—'}</dd>
50
+ <dt style="opacity:.6">Where</dt><dd style="margin:0">${context.embedLocation ?? '—'}</dd>
51
+ </dl>
52
+ <pre style="margin:16px 0 0;padding:12px;border-radius:8px;overflow:auto;font-size:12px;background:${
53
+ theme.paper || 'rgba(0,0,0,.04)'
54
+ }">${data ? escapeHtml(JSON.stringify(data, null, 2)) : 'Loading bookings…'}</pre>
55
+ </div>
56
+ `;
57
+ }
58
+
59
+ function escapeHtml(value) {
60
+ return value.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
61
+ }
62
+
63
+ start().catch((err) => {
64
+ root.textContent = `Could not start: ${err.message}`;
65
+ });