@blotout/qinto-cli 0.1.20 → 0.1.21
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 +11 -1
- package/dist/index.js +3762 -3708
- package/dist/index.js.map +1 -1
- package/dist/worker-artifact/app-runtime/index.js +5 -2
- package/dist/worker-artifact/main/index.js +162 -125
- package/dist/worker-artifact/version.json +6 -6
- package/package.json +3 -3
- package/templates/app/full/qinto.app.jsonc +0 -129
- package/templates/app/full/src/browser.ts +0 -15
- package/templates/app/full/src/server.ts +0 -133
- package/templates/app/full/ui/src/pages/index.astro +0 -53
- package/templates/app/minimal/qinto.app.jsonc +0 -20
- package/templates/app/minimal/src/server.ts +0 -9
- package/templates/project/gitignore +0 -3
- package/templates/project/package.json +0 -13
- package/templates/project/tsconfig.json +0 -12
- package/templates/ui/astro.config.mjs +0 -6
- package/templates/ui/package.json +0 -12
- package/templates/ui/src/pages/index.astro +0 -50
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import type { AppUiContext } from '@blotout/qinto-sdk-app/server'
|
|
2
|
-
import { defineApp } from '@blotout/qinto-sdk-app/server'
|
|
3
|
-
|
|
4
|
-
type Env = { PURCHASES: D1Database }
|
|
5
|
-
|
|
6
|
-
// The purchases table comes from the migrations qinto.app.jsonc declares, applied at deploy.
|
|
7
|
-
const INSERT_PURCHASE = 'INSERT OR REPLACE INTO purchases (order_id, value, email_sha256, recorded_at) VALUES (?, ?, ?, ?)'
|
|
8
|
-
|
|
9
|
-
const sha256Hex = async (value: string): Promise<string> => {
|
|
10
|
-
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value.trim().toLowerCase()))
|
|
11
|
-
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// The panel posts an install id; it is input, never authority. The handler resolves it against
|
|
15
|
-
// ctx.installs, which the platform built from the site's own registry.
|
|
16
|
-
const analytics = async (request: Request, ctx: AppUiContext<Env>): Promise<Response> => {
|
|
17
|
-
const { installId } = (await request.json()) as { installId: string }
|
|
18
|
-
const target = ctx.installs.find((entry) => entry.id === installId)
|
|
19
|
-
if (target === undefined) {
|
|
20
|
-
return Response.json({ error: 'unknown install' }, { status: 404 })
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// That install's own secret, plus the app-scope secret you set once with `qinto app secret set`.
|
|
24
|
-
const token = target.secrets.API_KEY
|
|
25
|
-
const stats = await fetch(`${String(target.variables.ENDPOINT_URL)}/stats`, {
|
|
26
|
-
headers: { authorization: `Bearer ${token}`, 'x-app-secret': ctx.appSecrets.VENDOR_APP_SECRET }
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
return Response.json({ install: target.label, stats: await stats.json() })
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Writing a setting back needs `ui.writesSettings: true` in the manifest, and a viewer with write
|
|
33
|
-
// access. The value is authoritative at once but reaches ctx.variables on the next 5-minute sync —
|
|
34
|
-
// state your own code must read back immediately belongs in your own KV or D1.
|
|
35
|
-
const refresh = async (request: Request, ctx: AppUiContext<Env>): Promise<Response> => {
|
|
36
|
-
const { installId } = (await request.json()) as { installId: string }
|
|
37
|
-
if (ctx.viewer.access !== 'write') {
|
|
38
|
-
return Response.json({ error: 'read-only viewer' }, { status: 403 })
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
await ctx.settings.write(installId, { LAST_SYNCED_AT: new Date().toISOString() })
|
|
42
|
-
|
|
43
|
-
return Response.json({ refreshed: true })
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export default defineApp<Env>({
|
|
47
|
-
ui: async (request, ctx) => {
|
|
48
|
-
const path = new URL(request.url).pathname
|
|
49
|
-
if (path === '/analytics') {
|
|
50
|
-
return analytics(request, ctx)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (path === '/refresh') {
|
|
54
|
-
return refresh(request, ctx)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return Response.json({ error: 'not found' }, { status: 404 })
|
|
58
|
-
},
|
|
59
|
-
transform: {
|
|
60
|
-
track: async (action, ctx) => {
|
|
61
|
-
// Reshape the payload with ctx.replace, discard the event with ctx.drop, or let it through.
|
|
62
|
-
if (action.payload.event === 'Blocked Event') {
|
|
63
|
-
return ctx.drop()
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return ctx.replace({ ...action.payload, properties: { ...action.payload.properties, siteTag: ctx.variables.SITE_TAG } })
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
onEvent: {
|
|
70
|
-
track: async (action, ctx) => {
|
|
71
|
-
if (action.payload.event === 'Purchase') {
|
|
72
|
-
const email = ctx.user.properties.email
|
|
73
|
-
const emailHash = typeof email === 'string' ? await sha256Hex(email) : null
|
|
74
|
-
await ctx.env.PURCHASES.prepare(INSERT_PURCHASE)
|
|
75
|
-
.bind(String(action.payload.properties.orderId), Number(action.payload.properties.value), emailHash, action.receivedAt)
|
|
76
|
-
.run()
|
|
77
|
-
ctx.observability.log('purchase recorded', { orderId: String(action.payload.properties.orderId) })
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// A plain `fetch` here is traced for you and appears in the site's real-time console;
|
|
81
|
-
// report a delivery that failed with ctx.observability.error so the trace marks it.
|
|
82
|
-
const response = await fetch(String(ctx.variables.ENDPOINT_URL), {
|
|
83
|
-
method: 'POST',
|
|
84
|
-
headers: { 'content-type': 'application/json', authorization: `Bearer ${ctx.secrets.API_KEY}` },
|
|
85
|
-
body: JSON.stringify({
|
|
86
|
-
event: action.payload.event,
|
|
87
|
-
properties: action.payload.properties,
|
|
88
|
-
userId: action.userId,
|
|
89
|
-
context: action.context,
|
|
90
|
-
browser: action.browser
|
|
91
|
-
})
|
|
92
|
-
})
|
|
93
|
-
if (!response.ok) {
|
|
94
|
-
ctx.observability.error('delivery refused', undefined, { status: response.status, install: ctx.install.label })
|
|
95
|
-
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
ctx.observability.log('track delivered', { event: action.payload.event, install: ctx.install.label })
|
|
100
|
-
},
|
|
101
|
-
identify: async (action, ctx) => {
|
|
102
|
-
ctx.observability.log('identify received', { userId: action.userId, traits: Object.keys(action.payload.traits).length })
|
|
103
|
-
}
|
|
104
|
-
},
|
|
105
|
-
onLifecycle: {
|
|
106
|
-
installed: async (event, ctx) => {
|
|
107
|
-
ctx.observability.log('install ready', { eventId: event.eventId, install: ctx.install.label })
|
|
108
|
-
},
|
|
109
|
-
configurationChanged: async (event, ctx) => {
|
|
110
|
-
ctx.observability.log('configuration changed', {
|
|
111
|
-
eventId: event.eventId,
|
|
112
|
-
variables: event.changedVariables.length,
|
|
113
|
-
secrets: event.changedSecrets.length
|
|
114
|
-
})
|
|
115
|
-
},
|
|
116
|
-
uninstalled: async (event, ctx) => {
|
|
117
|
-
ctx.observability.log('install removed', { eventId: event.eventId, install: ctx.install.label })
|
|
118
|
-
}
|
|
119
|
-
},
|
|
120
|
-
// Keyed by the ids qinto.app.jsonc declares. Delivery is best effort — a run can be missed with no
|
|
121
|
-
// catch-up, and one whose response was lost may still have happened, so a handler must be safe to
|
|
122
|
-
// repeat. Work that must not be lost belongs in a workflow the handler starts.
|
|
123
|
-
schedules: {
|
|
124
|
-
'daily-rollup': async (event, ctx) => {
|
|
125
|
-
const counted = await ctx.env.PURCHASES.prepare('SELECT count(*) AS total FROM purchases').first<{ total: number }>()
|
|
126
|
-
ctx.observability.log('rollup complete', {
|
|
127
|
-
schedule: event.id,
|
|
128
|
-
install: ctx.install.label,
|
|
129
|
-
purchases: counted?.total ?? 0
|
|
130
|
-
})
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
})
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
<html lang='en'>
|
|
2
|
-
<head>
|
|
3
|
-
<meta charset='utf-8' />
|
|
4
|
-
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
|
5
|
-
<title>__APP_NAME__</title>
|
|
6
|
-
</head>
|
|
7
|
-
<body>
|
|
8
|
-
<main>
|
|
9
|
-
<h1>__APP_NAME__</h1>
|
|
10
|
-
<p>The whole loop: pick an install, the panel calls this app's own <code>ui</code> server handler, and the handler reads that install's secret and calls out with the app's own credential.</p>
|
|
11
|
-
|
|
12
|
-
<label for='install'>Install</label>
|
|
13
|
-
<select id='install'></select>
|
|
14
|
-
<button id='load' type='button'>Load stats</button>
|
|
15
|
-
<button id='refresh' type='button'>Record a sync</button>
|
|
16
|
-
|
|
17
|
-
<pre id='out'>Pick an install.</pre>
|
|
18
|
-
</main>
|
|
19
|
-
<script>
|
|
20
|
-
// Resolved from the app's own dependency one directory up; the panel declares only its build tooling.
|
|
21
|
-
import { createAppUi } from '@blotout/qinto-sdk-app/ui'
|
|
22
|
-
|
|
23
|
-
const ui = createAppUi()
|
|
24
|
-
const context = await ui.getContext()
|
|
25
|
-
const out = document.getElementById('out')
|
|
26
|
-
const select = document.getElementById('install')
|
|
27
|
-
|
|
28
|
-
select.replaceChildren(
|
|
29
|
-
...context.installs.map((install) => {
|
|
30
|
-
const option = document.createElement('option')
|
|
31
|
-
option.value = install.id
|
|
32
|
-
option.textContent = install.enabled ? install.label : `${install.label} (disabled)`
|
|
33
|
-
return option
|
|
34
|
-
})
|
|
35
|
-
)
|
|
36
|
-
select.value = context.appInstallId
|
|
37
|
-
|
|
38
|
-
// ui.fetch resolves the app's own handler and attaches the panel token. It returns the raw
|
|
39
|
-
// Response: parse it, and decide yourself what to do with a 429 or a 5xx.
|
|
40
|
-
const call = async (path) => {
|
|
41
|
-
const response = await ui.fetch(path, {
|
|
42
|
-
method: 'POST',
|
|
43
|
-
headers: { 'content-type': 'application/json' },
|
|
44
|
-
body: JSON.stringify({ installId: select.value })
|
|
45
|
-
})
|
|
46
|
-
out.textContent = `${response.status} ${JSON.stringify(await response.json(), null, 2)}`
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
document.getElementById('load').addEventListener('click', () => call('/analytics'))
|
|
50
|
-
document.getElementById('refresh').addEventListener('click', () => call('/refresh'))
|
|
51
|
-
</script>
|
|
52
|
-
</body>
|
|
53
|
-
</html>
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "./node_modules/@blotout/qinto-sdk-app/app.schema.json",
|
|
3
|
-
// The immutable identifier of the app on the Qinto platform.
|
|
4
|
-
"slug": "__APP_SLUG__",
|
|
5
|
-
"name": "__APP_NAME__",
|
|
6
|
-
// Where the app plugs into the event pipeline: "transform" and/or "destination".
|
|
7
|
-
"flows": ["destination"],
|
|
8
|
-
// Events reach the app only when the visitor granted this consent category.
|
|
9
|
-
"consent": { "category": "analytics" },
|
|
10
|
-
// Replace these with your own hosted documents before publishing.
|
|
11
|
-
"legal": {
|
|
12
|
-
"termsUrl": "https://example.com/terms",
|
|
13
|
-
"privacyUrl": "https://example.com/privacy"
|
|
14
|
-
},
|
|
15
|
-
"server": {
|
|
16
|
-
"entry": "src/server.ts",
|
|
17
|
-
// Must match the __hooks the server entry exports.
|
|
18
|
-
"hooks": { "transform": [], "onEvent": ["track"], "onConnect": false }
|
|
19
|
-
}
|
|
20
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "__APP_SLUG__",
|
|
3
|
-
"private": true,
|
|
4
|
-
"type": "module",
|
|
5
|
-
"dependencies": {
|
|
6
|
-
"@blotout/qinto-sdk-app": "__APP_SDK_SPECIFIER__",
|
|
7
|
-
"zod": "4.5.4"
|
|
8
|
-
},
|
|
9
|
-
"devDependencies": {
|
|
10
|
-
"@cloudflare/workers-types": "5.20260819.1",
|
|
11
|
-
"typescript": "7.0.2"
|
|
12
|
-
}
|
|
13
|
-
}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
<html lang='en'>
|
|
2
|
-
<head>
|
|
3
|
-
<meta charset='utf-8' />
|
|
4
|
-
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
|
5
|
-
<title>App panel</title>
|
|
6
|
-
</head>
|
|
7
|
-
<body>
|
|
8
|
-
<main>
|
|
9
|
-
<h1>App panel</h1>
|
|
10
|
-
<p>This panel renders inside the Qinto platform. Outside it the bridge stays inert, so the fields below stay empty.</p>
|
|
11
|
-
<dl>
|
|
12
|
-
<dt>Site</dt>
|
|
13
|
-
<dd id='site'></dd>
|
|
14
|
-
<dt>Version</dt>
|
|
15
|
-
<dd id='version'></dd>
|
|
16
|
-
</dl>
|
|
17
|
-
<h2>Installs</h2>
|
|
18
|
-
<p>Every install the site made of this app, with the values it configured. A variable the manifest declares as a secret never reaches this panel — your server handler receives those.</p>
|
|
19
|
-
<ul id='installs'></ul>
|
|
20
|
-
</main>
|
|
21
|
-
<script>
|
|
22
|
-
// Resolved from the app's own dependency one directory up; the panel declares only its build tooling.
|
|
23
|
-
import { createAppUi } from '@blotout/qinto-sdk-app/ui'
|
|
24
|
-
|
|
25
|
-
const ui = createAppUi()
|
|
26
|
-
const context = await ui.getContext()
|
|
27
|
-
|
|
28
|
-
document.getElementById('site').textContent = context.siteOrigin
|
|
29
|
-
document.getElementById('version').textContent = context.appVersion
|
|
30
|
-
|
|
31
|
-
const line = (install) => {
|
|
32
|
-
const values = Object.entries(install.variables).map(([name, value]) => `${name}=${value}`)
|
|
33
|
-
const own = install.id === context.appInstallId ? ' — this panel' : ''
|
|
34
|
-
return `${install.label}${own}: ${values.join(', ') || 'nothing configured yet'}`
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
document.getElementById('installs').replaceChildren(
|
|
38
|
-
...context.installs.map((install) => {
|
|
39
|
-
const item = document.createElement('li')
|
|
40
|
-
item.textContent = line(install)
|
|
41
|
-
return item
|
|
42
|
-
})
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
// ui.fetch(path) calls this app's own `ui` server handler with the panel token attached —
|
|
46
|
-
// that is where a secret, an app-scope credential, or a settings write belongs. getToken() is
|
|
47
|
-
// for calling your own endpoints instead.
|
|
48
|
-
</script>
|
|
49
|
-
</body>
|
|
50
|
-
</html>
|