@odeva/cli 0.0.1
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/LICENSE +21 -0
- package/README.md +150 -0
- package/bin/dev.js +7 -0
- package/bin/run.js +4 -0
- package/dist/commands/app/config/link.js +93 -0
- package/dist/commands/app/config/rotate-secret.js +74 -0
- package/dist/commands/app/dev.js +127 -0
- package/dist/commands/app/init.js +124 -0
- package/dist/commands/app/status.js +59 -0
- package/dist/commands/app/submit.js +85 -0
- package/dist/commands/auth/login.js +136 -0
- package/dist/commands/auth/logout.js +20 -0
- package/dist/commands/auth/select-org.js +30 -0
- package/dist/commands/auth/whoami.js +24 -0
- package/dist/commands/version.js +17 -0
- package/dist/commands/webhook/list.js +50 -0
- package/dist/commands/webhook/trigger.js +128 -0
- package/dist/index.js +4 -0
- package/dist/lib/api.js +276 -0
- package/dist/lib/app-env.js +39 -0
- package/dist/lib/cloudflared.js +93 -0
- package/dist/lib/config.js +51 -0
- package/dist/lib/credentials.js +50 -0
- package/dist/lib/dev-runner.js +115 -0
- package/dist/lib/errors.js +41 -0
- package/dist/lib/open-url.js +29 -0
- package/dist/lib/org-picker.js +34 -0
- package/dist/lib/paths.js +13 -0
- package/dist/lib/run.js +17 -0
- package/dist/lib/slug.js +10 -0
- package/dist/lib/templates.js +115 -0
- package/dist/lib/ui.js +13 -0
- package/dist/lib/webhook-fixtures.js +60 -0
- package/package.json +74 -0
- package/templates/hono-bun/.gitignore.tmpl +9 -0
- package/templates/hono-bun/README.md.tmpl +23 -0
- package/templates/hono-bun/odeva.app.toml.tmpl +25 -0
- package/templates/hono-bun/package.json.tmpl +19 -0
- package/templates/hono-bun/src/index.ts +49 -0
- package/templates/hono-bun/src/install.ts +97 -0
- package/templates/hono-bun/src/installations.ts +84 -0
- package/templates/hono-bun/src/webhook.ts +16 -0
- package/templates/hono-bun/tsconfig.json +16 -0
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { GraphQLClient, ClientError } from "graphql-request";
|
|
2
|
+
import { loadCredentials } from "./credentials.js";
|
|
3
|
+
import { ApiError, NotAuthenticatedError } from "./errors.js";
|
|
4
|
+
import { DEFAULT_API_URL } from "./paths.js";
|
|
5
|
+
class OdevaApi {
|
|
6
|
+
client;
|
|
7
|
+
endpoint;
|
|
8
|
+
authenticated;
|
|
9
|
+
constructor(opts = {}) {
|
|
10
|
+
const creds = opts.credentials ?? loadCredentials();
|
|
11
|
+
const apiUrl = opts.apiUrl ?? creds?.apiUrl ?? DEFAULT_API_URL;
|
|
12
|
+
this.endpoint = opts.endpoint ?? `${apiUrl.replace(/\/$/, "")}/graphql`;
|
|
13
|
+
this.authenticated = Boolean(creds?.token);
|
|
14
|
+
this.client = new GraphQLClient(this.endpoint, {
|
|
15
|
+
headers: {
|
|
16
|
+
...creds?.token ? { authorization: `Bearer ${creds.token}` } : {},
|
|
17
|
+
...creds?.organizationId ? { "x-organization-id": creds.organizationId } : {},
|
|
18
|
+
"user-agent": userAgent()
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
requireAuth() {
|
|
23
|
+
if (!this.authenticated) throw new NotAuthenticatedError();
|
|
24
|
+
}
|
|
25
|
+
async request(query, variables) {
|
|
26
|
+
try {
|
|
27
|
+
return await this.client.request(query, variables);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err instanceof ClientError) {
|
|
30
|
+
const gqlErrors = err.response.errors;
|
|
31
|
+
const first = gqlErrors?.[0]?.message ?? err.message;
|
|
32
|
+
throw new ApiError(`Odeva API error: ${first}`, {
|
|
33
|
+
graphqlErrors: gqlErrors
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
37
|
+
throw new ApiError(`Could not reach ${this.endpoint}: ${message}`, {
|
|
38
|
+
hint: "Is the Odeva API running? Pass --api-url, or set ODEVA_API_URL."
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Lightweight auth probe — succeeds if the token resolves to a user. */
|
|
43
|
+
async ping() {
|
|
44
|
+
this.requireAuth();
|
|
45
|
+
const data = await this.request(`
|
|
46
|
+
query OdevaCliWhoami { session { user { email } } }
|
|
47
|
+
`);
|
|
48
|
+
return { ok: true, email: data.session?.user?.email ?? null };
|
|
49
|
+
}
|
|
50
|
+
async session() {
|
|
51
|
+
this.requireAuth();
|
|
52
|
+
const data = await this.request(`
|
|
53
|
+
query OdevaCliSession {
|
|
54
|
+
session {
|
|
55
|
+
user { email }
|
|
56
|
+
organizations { id slug name }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
`);
|
|
60
|
+
return {
|
|
61
|
+
email: data.session?.user?.email ?? null,
|
|
62
|
+
organizations: data.session?.organizations ?? []
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async cliAuthBegin(hostname) {
|
|
66
|
+
const data = await this.request(
|
|
67
|
+
`mutation OdevaCliAuthBegin($hostname: String!) {
|
|
68
|
+
cliAuthBegin(hostname: $hostname) {
|
|
69
|
+
deviceCode
|
|
70
|
+
userCode
|
|
71
|
+
verificationUri
|
|
72
|
+
expiresIn
|
|
73
|
+
interval
|
|
74
|
+
}
|
|
75
|
+
}`,
|
|
76
|
+
{ hostname }
|
|
77
|
+
);
|
|
78
|
+
return data.cliAuthBegin;
|
|
79
|
+
}
|
|
80
|
+
async cliAuthPoll(deviceCode) {
|
|
81
|
+
const data = await this.request(
|
|
82
|
+
`mutation OdevaCliAuthPoll($deviceCode: String!) {
|
|
83
|
+
cliAuthPoll(deviceCode: $deviceCode) {
|
|
84
|
+
status
|
|
85
|
+
token
|
|
86
|
+
}
|
|
87
|
+
}`,
|
|
88
|
+
{ deviceCode }
|
|
89
|
+
);
|
|
90
|
+
return data.cliAuthPoll;
|
|
91
|
+
}
|
|
92
|
+
async listDeveloperApps() {
|
|
93
|
+
const data = await this.request(`
|
|
94
|
+
query OdevaCliListDeveloperApps {
|
|
95
|
+
developerApps {
|
|
96
|
+
id
|
|
97
|
+
name
|
|
98
|
+
slug
|
|
99
|
+
clientId
|
|
100
|
+
homepageUrl
|
|
101
|
+
installUrl
|
|
102
|
+
webhookUrl
|
|
103
|
+
requestedScopes
|
|
104
|
+
publicationStatus
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
`);
|
|
108
|
+
return data.developerApps;
|
|
109
|
+
}
|
|
110
|
+
async findAppByClientId(clientId) {
|
|
111
|
+
const apps = await this.listDeveloperApps();
|
|
112
|
+
return apps.find((a) => a.clientId === clientId) ?? null;
|
|
113
|
+
}
|
|
114
|
+
/** Same as findAppByClientId but pulls review-status fields. Requires the
|
|
115
|
+
* `reviewNotes` column on App (added 2026-06-04). */
|
|
116
|
+
async findAppWithReviewByClientId(clientId) {
|
|
117
|
+
const data = await this.request(`
|
|
118
|
+
query OdevaCliListDeveloperAppsWithReview {
|
|
119
|
+
developerApps {
|
|
120
|
+
id name slug clientId homepageUrl installUrl webhookUrl
|
|
121
|
+
requestedScopes publicationStatus reviewNotes verifiedAt updatedAt
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
`);
|
|
125
|
+
return data.developerApps.find((a) => a.clientId === clientId) ?? null;
|
|
126
|
+
}
|
|
127
|
+
async submitDeveloperApp(id) {
|
|
128
|
+
const data = await this.request(
|
|
129
|
+
`mutation OdevaCliSubmitDeveloperApp($id: ID!) {
|
|
130
|
+
submitDeveloperApp(id: $id) {
|
|
131
|
+
app {
|
|
132
|
+
id name slug clientId homepageUrl installUrl webhookUrl
|
|
133
|
+
requestedScopes publicationStatus reviewNotes verifiedAt updatedAt
|
|
134
|
+
}
|
|
135
|
+
errors
|
|
136
|
+
}
|
|
137
|
+
}`,
|
|
138
|
+
{ id }
|
|
139
|
+
);
|
|
140
|
+
return data.submitDeveloperApp;
|
|
141
|
+
}
|
|
142
|
+
async upsertDeveloperApp(input) {
|
|
143
|
+
const data = await this.request(
|
|
144
|
+
`
|
|
145
|
+
mutation OdevaCliUpsertDeveloperApp(
|
|
146
|
+
$id: ID
|
|
147
|
+
$name: String!
|
|
148
|
+
$slug: String!
|
|
149
|
+
$authorName: String
|
|
150
|
+
$description: String
|
|
151
|
+
$homepageUrl: String
|
|
152
|
+
$installUrl: String
|
|
153
|
+
$privacyUrl: String
|
|
154
|
+
$requestedScopes: [String!]
|
|
155
|
+
$webhookUrl: String
|
|
156
|
+
) {
|
|
157
|
+
upsertDeveloperApp(
|
|
158
|
+
id: $id
|
|
159
|
+
name: $name
|
|
160
|
+
slug: $slug
|
|
161
|
+
authorName: $authorName
|
|
162
|
+
description: $description
|
|
163
|
+
homepageUrl: $homepageUrl
|
|
164
|
+
installUrl: $installUrl
|
|
165
|
+
privacyUrl: $privacyUrl
|
|
166
|
+
requestedScopes: $requestedScopes
|
|
167
|
+
webhookUrl: $webhookUrl
|
|
168
|
+
) {
|
|
169
|
+
app {
|
|
170
|
+
id
|
|
171
|
+
name
|
|
172
|
+
slug
|
|
173
|
+
clientId
|
|
174
|
+
homepageUrl
|
|
175
|
+
installUrl
|
|
176
|
+
webhookUrl
|
|
177
|
+
requestedScopes
|
|
178
|
+
publicationStatus
|
|
179
|
+
}
|
|
180
|
+
rawClientSecret
|
|
181
|
+
errors
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
`,
|
|
185
|
+
input
|
|
186
|
+
);
|
|
187
|
+
const payload = data.upsertDeveloperApp;
|
|
188
|
+
if (payload.errors?.length) {
|
|
189
|
+
throw new ApiError(`Could not save app: ${payload.errors.join(", ")}`);
|
|
190
|
+
}
|
|
191
|
+
return { app: payload.app, rawClientSecret: payload.rawClientSecret };
|
|
192
|
+
}
|
|
193
|
+
async rotateDeveloperAppSecret(id) {
|
|
194
|
+
const data = await this.request(
|
|
195
|
+
`mutation OdevaCliRotateDeveloperAppSecret($id: ID!) {
|
|
196
|
+
rotateDeveloperAppSecret(id: $id) {
|
|
197
|
+
app {
|
|
198
|
+
id name slug clientId homepageUrl installUrl webhookUrl
|
|
199
|
+
requestedScopes publicationStatus
|
|
200
|
+
}
|
|
201
|
+
rawClientSecret
|
|
202
|
+
errors
|
|
203
|
+
}
|
|
204
|
+
}`,
|
|
205
|
+
{ id }
|
|
206
|
+
);
|
|
207
|
+
const payload = data.rotateDeveloperAppSecret;
|
|
208
|
+
if (payload.errors?.length || !payload.app || !payload.rawClientSecret) {
|
|
209
|
+
throw new ApiError(`Could not rotate secret: ${payload.errors.join(", ") || "no secret returned"}`);
|
|
210
|
+
}
|
|
211
|
+
return { app: payload.app, rawClientSecret: payload.rawClientSecret };
|
|
212
|
+
}
|
|
213
|
+
async webhookEventTypes() {
|
|
214
|
+
const data = await this.request(`
|
|
215
|
+
query OdevaCliWebhookEventTypes { webhookEventTypes }
|
|
216
|
+
`);
|
|
217
|
+
return data.webhookEventTypes;
|
|
218
|
+
}
|
|
219
|
+
async webhookSubscriptions() {
|
|
220
|
+
const data = await this.request(`
|
|
221
|
+
query OdevaCliWebhookSubscriptions {
|
|
222
|
+
webhookSubscriptions {
|
|
223
|
+
id
|
|
224
|
+
name
|
|
225
|
+
endpointUrl
|
|
226
|
+
eventTypes
|
|
227
|
+
status
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
`);
|
|
231
|
+
return data.webhookSubscriptions;
|
|
232
|
+
}
|
|
233
|
+
async createWebhookSubscription(input) {
|
|
234
|
+
const data = await this.request(
|
|
235
|
+
`
|
|
236
|
+
mutation OdevaCliCreateWebhookSubscription(
|
|
237
|
+
$name: String!
|
|
238
|
+
$endpointUrl: String!
|
|
239
|
+
$eventTypes: [String!]!
|
|
240
|
+
$appInstallationId: ID
|
|
241
|
+
) {
|
|
242
|
+
createWebhookSubscription(
|
|
243
|
+
name: $name
|
|
244
|
+
endpointUrl: $endpointUrl
|
|
245
|
+
eventTypes: $eventTypes
|
|
246
|
+
appInstallationId: $appInstallationId
|
|
247
|
+
) {
|
|
248
|
+
webhookSubscription { id name endpointUrl eventTypes status }
|
|
249
|
+
webhookSecret
|
|
250
|
+
errors
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
`,
|
|
254
|
+
input
|
|
255
|
+
);
|
|
256
|
+
const payload = data.createWebhookSubscription;
|
|
257
|
+
if (payload.errors?.length) {
|
|
258
|
+
throw new ApiError(`Could not create webhook subscription: ${payload.errors.join(", ")}`);
|
|
259
|
+
}
|
|
260
|
+
return { subscription: payload.webhookSubscription, webhookSecret: payload.webhookSecret };
|
|
261
|
+
}
|
|
262
|
+
async deleteWebhookSubscription(id) {
|
|
263
|
+
await this.request(
|
|
264
|
+
`mutation OdevaCliDeleteWebhookSubscription($id: ID!) {
|
|
265
|
+
deleteWebhookSubscription(id: $id) { webhookSubscription { id } }
|
|
266
|
+
}`,
|
|
267
|
+
{ id }
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function userAgent() {
|
|
272
|
+
return `odeva-cli/0.0.1 (${process.platform}; ${process.arch}; node-${process.versions.node})`;
|
|
273
|
+
}
|
|
274
|
+
export {
|
|
275
|
+
OdevaApi
|
|
276
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
const APP_ENV_FILE = ".odeva.env";
|
|
4
|
+
function loadAppEnv(appConfigPath) {
|
|
5
|
+
const envPath = join(dirname(appConfigPath), APP_ENV_FILE);
|
|
6
|
+
if (!existsSync(envPath)) return {};
|
|
7
|
+
const raw = readFileSync(envPath, "utf8");
|
|
8
|
+
const result = {};
|
|
9
|
+
for (const line of raw.split("\n")) {
|
|
10
|
+
const trimmed = line.trim();
|
|
11
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
12
|
+
const eq = trimmed.indexOf("=");
|
|
13
|
+
if (eq < 0) continue;
|
|
14
|
+
const key = trimmed.slice(0, eq).trim();
|
|
15
|
+
const value = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
|
|
16
|
+
if (key) result[key] = value;
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
function saveAppEnv(appConfigPath, values) {
|
|
21
|
+
const envPath = join(dirname(appConfigPath), APP_ENV_FILE);
|
|
22
|
+
const merged = { ...loadAppEnv(appConfigPath), ...values };
|
|
23
|
+
const body = [
|
|
24
|
+
"# Auto-managed by `odeva` \u2014 do not commit. Add `.odeva.env` to .gitignore.",
|
|
25
|
+
...Object.entries(merged).map(([k, v]) => `${k}=${v}`),
|
|
26
|
+
""
|
|
27
|
+
].join("\n");
|
|
28
|
+
writeFileSync(envPath, body, { mode: 384 });
|
|
29
|
+
try {
|
|
30
|
+
chmodSync(envPath, 384);
|
|
31
|
+
} catch {
|
|
32
|
+
}
|
|
33
|
+
return envPath;
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
APP_ENV_FILE,
|
|
37
|
+
loadAppEnv,
|
|
38
|
+
saveAppEnv
|
|
39
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
5
|
+
function isCloudflaredInstalled() {
|
|
6
|
+
const result = spawnSync("cloudflared", ["--version"], { stdio: "ignore" });
|
|
7
|
+
return result.status === 0;
|
|
8
|
+
}
|
|
9
|
+
function startQuickTunnel(localPort, options = {}) {
|
|
10
|
+
if (!isCloudflaredInstalled()) {
|
|
11
|
+
throw new CliError("`cloudflared` is not installed or not on PATH.", {
|
|
12
|
+
hint: "Install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ (e.g. `brew install cloudflared` on macOS, `apt install cloudflared` on Debian/Ubuntu)."
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const proc = spawn(
|
|
17
|
+
"cloudflared",
|
|
18
|
+
["tunnel", "--no-autoupdate", "--url", `http://localhost:${localPort}`],
|
|
19
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
20
|
+
);
|
|
21
|
+
let resolved = false;
|
|
22
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
if (resolved) return;
|
|
25
|
+
cleanup();
|
|
26
|
+
reject(
|
|
27
|
+
new CliError(`cloudflared did not produce a tunnel URL within ${timeoutMs / 1e3}s.`, {
|
|
28
|
+
hint: "Check your network connection or run `cloudflared tunnel --url http://localhost:" + localPort + "` manually."
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
}, timeoutMs);
|
|
32
|
+
const onData = (chunk) => {
|
|
33
|
+
const text = chunk.toString("utf8");
|
|
34
|
+
const match = TRYCLOUDFLARE_RE.exec(text);
|
|
35
|
+
if (match && !resolved) {
|
|
36
|
+
resolved = true;
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
resolve({
|
|
39
|
+
url: match[0],
|
|
40
|
+
process: proc,
|
|
41
|
+
stop: () => stopProcess(proc)
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
proc.stdout?.on("data", onData);
|
|
46
|
+
proc.stderr?.on("data", onData);
|
|
47
|
+
proc.on("error", (err) => {
|
|
48
|
+
if (resolved) return;
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
reject(new CliError(`Failed to start cloudflared: ${err.message}`));
|
|
51
|
+
});
|
|
52
|
+
proc.on("exit", (code) => {
|
|
53
|
+
if (resolved) return;
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
reject(new CliError(`cloudflared exited with code ${code} before producing a URL.`));
|
|
56
|
+
});
|
|
57
|
+
function cleanup() {
|
|
58
|
+
try {
|
|
59
|
+
proc.kill("SIGTERM");
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function stopProcess(proc) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
if (proc.exitCode !== null) return resolve();
|
|
68
|
+
proc.once("exit", () => resolve());
|
|
69
|
+
try {
|
|
70
|
+
proc.kill("SIGTERM");
|
|
71
|
+
} catch {
|
|
72
|
+
resolve();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
if (proc.exitCode === null) {
|
|
77
|
+
try {
|
|
78
|
+
proc.kill("SIGKILL");
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}, 2e3);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function parseTunnelUrl(text) {
|
|
86
|
+
const match = TRYCLOUDFLARE_RE.exec(text);
|
|
87
|
+
return match ? match[0] : null;
|
|
88
|
+
}
|
|
89
|
+
export {
|
|
90
|
+
isCloudflaredInstalled,
|
|
91
|
+
parseTunnelUrl,
|
|
92
|
+
startQuickTunnel
|
|
93
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { parse, stringify } from "smol-toml";
|
|
4
|
+
import { APP_CONFIG_FILE } from "./paths.js";
|
|
5
|
+
import { AppConfigNotFoundError, CliError } from "./errors.js";
|
|
6
|
+
function findAppConfigPath(startDir = process.cwd()) {
|
|
7
|
+
let dir = resolve(startDir);
|
|
8
|
+
while (true) {
|
|
9
|
+
const candidate = join(dir, APP_CONFIG_FILE);
|
|
10
|
+
if (existsSync(candidate)) return candidate;
|
|
11
|
+
const parent = dirname(dir);
|
|
12
|
+
if (parent === dir) return null;
|
|
13
|
+
dir = parent;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function loadAppConfig(startDir = process.cwd()) {
|
|
17
|
+
const path = findAppConfigPath(startDir);
|
|
18
|
+
if (!path) throw new AppConfigNotFoundError();
|
|
19
|
+
const raw = readFileSync(path, "utf8");
|
|
20
|
+
const parsed = parse(raw);
|
|
21
|
+
validateConfig(parsed, path);
|
|
22
|
+
return { config: parsed, path, root: dirname(path) };
|
|
23
|
+
}
|
|
24
|
+
function saveAppConfig(loaded) {
|
|
25
|
+
validateConfig(loaded.config, loaded.path);
|
|
26
|
+
writeFileSync(loaded.path, stringify(loaded.config) + "\n", "utf8");
|
|
27
|
+
}
|
|
28
|
+
function writeAppConfigAt(path, config) {
|
|
29
|
+
validateConfig(config, path);
|
|
30
|
+
writeFileSync(path, stringify(config) + "\n", "utf8");
|
|
31
|
+
}
|
|
32
|
+
function validateConfig(config, path) {
|
|
33
|
+
if (!config.name || typeof config.name !== "string") {
|
|
34
|
+
throw new CliError(`Invalid ${APP_CONFIG_FILE}: missing 'name'.`, { hint: `Edit ${path}` });
|
|
35
|
+
}
|
|
36
|
+
if (!config.slug || typeof config.slug !== "string") {
|
|
37
|
+
throw new CliError(`Invalid ${APP_CONFIG_FILE}: missing 'slug'.`, { hint: `Edit ${path}` });
|
|
38
|
+
}
|
|
39
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(config.slug)) {
|
|
40
|
+
throw new CliError(
|
|
41
|
+
`Invalid 'slug' in ${APP_CONFIG_FILE}: '${config.slug}'. Use lowercase letters, digits, and hyphens.`,
|
|
42
|
+
{ hint: `Edit ${path}` }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
findAppConfigPath,
|
|
48
|
+
loadAppConfig,
|
|
49
|
+
saveAppConfig,
|
|
50
|
+
writeAppConfigAt
|
|
51
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { CONFIG_DIR, CREDENTIALS_PATH, DEFAULT_API_URL } from "./paths.js";
|
|
4
|
+
import { NotAuthenticatedError } from "./errors.js";
|
|
5
|
+
function loadCredentials() {
|
|
6
|
+
if (!existsSync(CREDENTIALS_PATH)) return null;
|
|
7
|
+
try {
|
|
8
|
+
const raw = readFileSync(CREDENTIALS_PATH, "utf8");
|
|
9
|
+
const parsed = JSON.parse(raw);
|
|
10
|
+
if (!parsed.token) return null;
|
|
11
|
+
return parsed;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function requireCredentials() {
|
|
17
|
+
const creds = loadCredentials();
|
|
18
|
+
if (!creds) throw new NotAuthenticatedError();
|
|
19
|
+
return creds;
|
|
20
|
+
}
|
|
21
|
+
function saveCredentials(partial) {
|
|
22
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
23
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
24
|
+
}
|
|
25
|
+
const existing = loadCredentials();
|
|
26
|
+
const creds = {
|
|
27
|
+
apiUrl: DEFAULT_API_URL,
|
|
28
|
+
...existing,
|
|
29
|
+
...partial,
|
|
30
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
31
|
+
};
|
|
32
|
+
writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
33
|
+
try {
|
|
34
|
+
chmodSync(CREDENTIALS_PATH, 384);
|
|
35
|
+
chmodSync(dirname(CREDENTIALS_PATH), 448);
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
return creds;
|
|
39
|
+
}
|
|
40
|
+
function clearCredentials() {
|
|
41
|
+
if (!existsSync(CREDENTIALS_PATH)) return false;
|
|
42
|
+
unlinkSync(CREDENTIALS_PATH);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
export {
|
|
46
|
+
clearCredentials,
|
|
47
|
+
loadCredentials,
|
|
48
|
+
requireCredentials,
|
|
49
|
+
saveCredentials
|
|
50
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
async function registerWebhookSubscriptions(api, appName, tunnelUrl, subscriptions) {
|
|
5
|
+
const created = [];
|
|
6
|
+
for (const sub of subscriptions) {
|
|
7
|
+
const fullUrl = joinUrl(tunnelUrl, sub.uri);
|
|
8
|
+
const { subscription, webhookSecret } = await api.createWebhookSubscription({
|
|
9
|
+
name: `${appName} (dev) \u2014 ${sub.topic}`,
|
|
10
|
+
endpointUrl: fullUrl,
|
|
11
|
+
eventTypes: [sub.topic]
|
|
12
|
+
});
|
|
13
|
+
created.push({ config: sub, subscription, secret: webhookSecret, fullUrl });
|
|
14
|
+
}
|
|
15
|
+
return created;
|
|
16
|
+
}
|
|
17
|
+
async function cleanupSubscriptions(api, registered) {
|
|
18
|
+
for (const reg of registered) {
|
|
19
|
+
try {
|
|
20
|
+
await api.deleteWebhookSubscription(reg.subscription.id);
|
|
21
|
+
} catch {
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function spawnDevServer(opts) {
|
|
26
|
+
const command = opts.config.build?.dev ?? "bun run dev";
|
|
27
|
+
const proc = spawn(command, {
|
|
28
|
+
cwd: opts.cwd,
|
|
29
|
+
shell: true,
|
|
30
|
+
stdio: "inherit",
|
|
31
|
+
env: {
|
|
32
|
+
...process.env,
|
|
33
|
+
...opts.env
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
process: proc,
|
|
38
|
+
stop: () => stopProcess(proc),
|
|
39
|
+
waitForExit: () => new Promise((resolve) => {
|
|
40
|
+
if (proc.exitCode !== null) return resolve(proc.exitCode);
|
|
41
|
+
proc.once("exit", (code) => resolve(code));
|
|
42
|
+
})
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function writeDevEnvFile(cwd, registered) {
|
|
46
|
+
if (registered.length === 0) return null;
|
|
47
|
+
const path = join(cwd, ".env.odeva.local");
|
|
48
|
+
const lines = [
|
|
49
|
+
"# Auto-generated by `odeva app dev`. Do not commit.",
|
|
50
|
+
"# Reload your dev server to pick up these values."
|
|
51
|
+
];
|
|
52
|
+
const primary = registered[0];
|
|
53
|
+
lines.push(`ODEVA_WEBHOOK_SECRET=${primary.secret}`);
|
|
54
|
+
for (const reg of registered) {
|
|
55
|
+
lines.push(`# ${reg.config.topic} \u2192 ${reg.fullUrl}`);
|
|
56
|
+
lines.push(`ODEVA_WEBHOOK_SECRET__${secretEnvKey(reg.config.topic)}=${reg.secret}`);
|
|
57
|
+
}
|
|
58
|
+
writeFileSync(path, lines.join("\n") + "\n", { mode: 384 });
|
|
59
|
+
return path;
|
|
60
|
+
}
|
|
61
|
+
function secretEnvKey(topic) {
|
|
62
|
+
return topic.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
63
|
+
}
|
|
64
|
+
function joinUrl(base, path) {
|
|
65
|
+
const trimmedBase = base.replace(/\/$/, "");
|
|
66
|
+
const trimmedPath = path.startsWith("/") ? path : `/${path}`;
|
|
67
|
+
return `${trimmedBase}${trimmedPath}`;
|
|
68
|
+
}
|
|
69
|
+
function stopProcess(proc) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
if (proc.exitCode !== null) return resolve();
|
|
72
|
+
proc.once("exit", () => resolve());
|
|
73
|
+
try {
|
|
74
|
+
proc.kill("SIGTERM");
|
|
75
|
+
} catch {
|
|
76
|
+
resolve();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
if (proc.exitCode === null) {
|
|
81
|
+
try {
|
|
82
|
+
proc.kill("SIGKILL");
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}, 2e3);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function preflightChecks(cwd) {
|
|
90
|
+
const warnings = [];
|
|
91
|
+
if (!existsSync(join(cwd, "node_modules"))) {
|
|
92
|
+
warnings.push("node_modules/ not found \u2014 run `bun install` first.");
|
|
93
|
+
}
|
|
94
|
+
return { warnings };
|
|
95
|
+
}
|
|
96
|
+
function readEnvFile(path) {
|
|
97
|
+
if (!existsSync(path)) return {};
|
|
98
|
+
const out = {};
|
|
99
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
100
|
+
const trimmed = line.trim();
|
|
101
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
102
|
+
const eq = trimmed.indexOf("=");
|
|
103
|
+
if (eq === -1) continue;
|
|
104
|
+
out[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
export {
|
|
109
|
+
cleanupSubscriptions,
|
|
110
|
+
preflightChecks,
|
|
111
|
+
readEnvFile,
|
|
112
|
+
registerWebhookSubscriptions,
|
|
113
|
+
spawnDevServer,
|
|
114
|
+
writeDevEnvFile
|
|
115
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
class CliError extends Error {
|
|
2
|
+
exitCode;
|
|
3
|
+
hint;
|
|
4
|
+
constructor(message, options = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "CliError";
|
|
7
|
+
this.exitCode = options.exitCode ?? 1;
|
|
8
|
+
this.hint = options.hint;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
class NotAuthenticatedError extends CliError {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("Not authenticated.", {
|
|
14
|
+
exitCode: 1,
|
|
15
|
+
hint: "Run `odeva auth login` first."
|
|
16
|
+
});
|
|
17
|
+
this.name = "NotAuthenticatedError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
class ApiError extends CliError {
|
|
21
|
+
graphqlErrors;
|
|
22
|
+
constructor(message, options = {}) {
|
|
23
|
+
super(message, { hint: options.hint });
|
|
24
|
+
this.name = "ApiError";
|
|
25
|
+
this.graphqlErrors = options.graphqlErrors;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
class AppConfigNotFoundError extends CliError {
|
|
29
|
+
constructor() {
|
|
30
|
+
super("No odeva.app.toml found in this directory or any parent.", {
|
|
31
|
+
hint: "Run `odeva app init` to scaffold a new app, or cd into an existing one."
|
|
32
|
+
});
|
|
33
|
+
this.name = "AppConfigNotFoundError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
ApiError,
|
|
38
|
+
AppConfigNotFoundError,
|
|
39
|
+
CliError,
|
|
40
|
+
NotAuthenticatedError
|
|
41
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
function openUrl(url) {
|
|
3
|
+
const platform = process.platform;
|
|
4
|
+
let command;
|
|
5
|
+
let args;
|
|
6
|
+
if (platform === "darwin") {
|
|
7
|
+
command = "open";
|
|
8
|
+
args = [url];
|
|
9
|
+
} else if (platform === "win32") {
|
|
10
|
+
command = "cmd";
|
|
11
|
+
args = ["/c", "start", "", url];
|
|
12
|
+
} else {
|
|
13
|
+
command = "xdg-open";
|
|
14
|
+
args = [url];
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const child = spawn(command, args, {
|
|
18
|
+
detached: true,
|
|
19
|
+
stdio: "ignore"
|
|
20
|
+
});
|
|
21
|
+
child.unref();
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export {
|
|
28
|
+
openUrl
|
|
29
|
+
};
|