@inglorious/create-app 1.11.0 → 1.13.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 +2 -2
- package/bin/index.js +5 -0
- package/bin/lib/app.js +105 -10
- package/bin/lib/constants.js +1 -0
- package/bin/lib/features.js +59 -0
- package/bin/lib/file.js +32 -0
- package/bin/lib/firebase.js +2 -16
- package/bin/lib/mobile.js +53 -2
- package/bin/lib/native-update.js +119 -0
- package/bin/lib/skills.js +1 -0
- package/bin/lib/source.js +1 -0
- package/package.json +1 -1
- package/templates/features/native-update/supabase/migrations/native_update.sql +17 -0
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ For the JavaScript and TypeScript Vite templates, the CLI then asks about the ap
|
|
|
31
31
|
| Add push notifications? | `--push` / `--no-push` | Firebase Cloud Messaging, a permission prompt and the Supabase side of sending; turns on PWA support |
|
|
32
32
|
| Enable PWA support? | `--pwa` / `--no-pwa` | `vite-plugin-pwa`, a manifest, icons, and a service worker that keeps installed apps on the latest version |
|
|
33
33
|
|
|
34
|
-
Every template except `minimal` then asks whether to add agent skills (`--skills` / `--no-skills`). Saying yes runs the [skills CLI](https://skills.sh) in the new project. It asks which coding agents to install for, and installs the [Inglorious Forge skills](https://github.com/IngloriousCoderz/forge-skills) that match your choices: `forge`, `forge-best-practices`, `store`, `web` and `web-best-practices` always, plus `ssx`, `vite-plugin-jsx` or `vite-plugin-vue`, `ui` and `ui-best-practices`, `i18n`, `firebase` and `supabase` when the project uses them. If the install fails, the CLI prints the command to retry it.
|
|
34
|
+
Every template except `minimal` then asks whether to add agent skills (`--skills` / `--no-skills`). Saying yes runs the [skills CLI](https://skills.sh) in the new project. It asks which coding agents to install for, and installs the [Inglorious Forge skills](https://github.com/IngloriousCoderz/forge-skills) that match your choices: `forge`, `forge-best-practices`, `app-recipe`, `store`, `web` and `web-best-practices` always, plus `ssx`, `vite-plugin-jsx` or `vite-plugin-vue`, `ui` and `ui-best-practices`, `i18n`, `firebase` and `supabase` when the project uses them. If the install fails, the CLI prints the command to retry it.
|
|
35
35
|
|
|
36
36
|
The app is generated in the syntax you chose: lit-html, JSX or Vue. Every question answered on the command line is skipped, so a project can be created without any prompt:
|
|
37
37
|
|
|
@@ -99,7 +99,7 @@ For Android and iOS builds, install Android Studio with the Android SDK and a Ja
|
|
|
99
99
|
|
|
100
100
|
Every Vite app is versioned with [Changesets](https://github.com/changesets/changesets): describe each change with `pnpm changeset`, and `pnpm release:version` raises the version in `package.json` and writes `CHANGELOG.md`. The app reads that version as `import.meta.env.VITE_APP_VERSION`.
|
|
101
101
|
|
|
102
|
-
Mobile apps also get `pnpm mobile:release`, which copies the version into the iOS and Android projects, raises the build number they share (every store upload needs a new one), builds with the values in `.env` rather than `.env.local`, and syncs. The generated README describes the whole release, upload included.
|
|
102
|
+
Mobile apps also get `pnpm mobile:release`, which copies the version into the iOS and Android projects, raises the build number they share (every store upload needs a new one), builds with the values in `.env` rather than `.env.local`, and syncs. The generated README describes the whole release, upload included. With `--push` too, an `app_versions` table in Supabase holds each platform's minimum build: an older install shows nothing but an update page, so raising it makes a release mandatory. Mobile apps also open links to their domain in the app: with routing, `watchAppLinks` from `@inglorious/web/app-links` sends the router to the link's path, and `public/.well-known/apple-app-site-association` is generated for iOS. The generated README lists the steps left: your Team ID, the Associated Domains capability, and serving that file as JSON.
|
|
103
103
|
|
|
104
104
|
## License
|
|
105
105
|
|
package/bin/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { SYNTAXES, TEMPLATES } from "./lib/features.js"
|
|
|
20
20
|
import { copyDir } from "./lib/file.js"
|
|
21
21
|
import { addFirebaseSupport } from "./lib/firebase.js"
|
|
22
22
|
import { addMobileSupport } from "./lib/mobile.js"
|
|
23
|
+
import { addNativeUpdateSupport } from "./lib/native-update.js"
|
|
23
24
|
import { addPwaSupport } from "./lib/pwa.js"
|
|
24
25
|
import { askAppFeatures } from "./lib/questions.js"
|
|
25
26
|
import { addReleaseSupport } from "./lib/release.js"
|
|
@@ -207,6 +208,10 @@ async function main() {
|
|
|
207
208
|
addFirebaseSupport(targetDir, pkg, { ...features, templatesRoot })
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
if (features.mobile && features.push) {
|
|
212
|
+
addNativeUpdateSupport(targetDir, templatesRoot)
|
|
213
|
+
}
|
|
214
|
+
|
|
210
215
|
for (const depType of ["dependencies", "devDependencies"]) {
|
|
211
216
|
if (pkg[depType]) {
|
|
212
217
|
for (const [name, version] of Object.entries(pkg[depType])) {
|
package/bin/lib/app.js
CHANGED
|
@@ -6,14 +6,21 @@ import * as jsxComponents from "./app-jsx.js"
|
|
|
6
6
|
import * as litComponents from "./app-lit.js"
|
|
7
7
|
import * as vueComponents from "./app-vue.js"
|
|
8
8
|
import {
|
|
9
|
+
CAPACITOR_APP_VERSION,
|
|
9
10
|
CAPACITOR_FIREBASE_VERSION,
|
|
10
11
|
CAPACITOR_VERSION,
|
|
11
12
|
FIREBASE_VERSION,
|
|
12
13
|
INDENTATION,
|
|
13
14
|
SUPABASE_VERSION,
|
|
14
15
|
} from "./constants.js"
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
ICON_PACKS,
|
|
18
|
+
LANGUAGES,
|
|
19
|
+
NATIVE_UPDATE_TEXTS,
|
|
20
|
+
PROMPT_TEXTS,
|
|
21
|
+
} from "./features.js"
|
|
16
22
|
import { importBlock } from "./imports.js"
|
|
23
|
+
import { nativeUpdateFiles } from "./native-update.js"
|
|
17
24
|
import { envExample, promptFiles } from "./prompts.js"
|
|
18
25
|
import {
|
|
19
26
|
appModel,
|
|
@@ -33,7 +40,8 @@ const COMPONENTS = {
|
|
|
33
40
|
/**
|
|
34
41
|
* Writes the source of an app with the features chosen: Inglorious UI,
|
|
35
42
|
* routing, page transitions, icon packs, internationalization, a mobile
|
|
36
|
-
* layout, analytics and push prompts,
|
|
43
|
+
* layout, analytics and push prompts, and the page that blocks outdated native
|
|
44
|
+
* builds, in lit-html, JSX or Vue. It replaces the template's hello world,
|
|
37
45
|
* keeping its message type as the example on the home page. The files come
|
|
38
46
|
* out as Prettier and the template's ESLint config would leave them.
|
|
39
47
|
*
|
|
@@ -60,6 +68,8 @@ export function addAppFeatures(targetDir, pkg, options) {
|
|
|
60
68
|
app.links = app.routing && !app.tabs
|
|
61
69
|
app.isVue = app.renderer === "vue"
|
|
62
70
|
app.hasPrompts = app.analytics || app.push
|
|
71
|
+
app.nativeUpdate = app.mobile && app.push
|
|
72
|
+
app.appLinks = app.mobile && app.routing
|
|
63
73
|
app.layout = layout(app)
|
|
64
74
|
|
|
65
75
|
const components = COMPONENTS[app.renderer]
|
|
@@ -93,6 +103,7 @@ export function addAppFeatures(targetDir, pkg, options) {
|
|
|
93
103
|
Object.assign(files, promptFiles(app))
|
|
94
104
|
files[".env.example"] = envExample(app)
|
|
95
105
|
}
|
|
106
|
+
if (app.nativeUpdate) Object.assign(files, nativeUpdateFiles(app))
|
|
96
107
|
if (app.isTypeScript) {
|
|
97
108
|
files["types/index.d.ts"] = typesSource(app)
|
|
98
109
|
}
|
|
@@ -140,6 +151,12 @@ function addDependencies(pkg, app) {
|
|
|
140
151
|
CAPACITOR_FIREBASE_VERSION
|
|
141
152
|
pkg.dependencies["@supabase/supabase-js"] = SUPABASE_VERSION
|
|
142
153
|
}
|
|
154
|
+
if (app.nativeUpdate || app.appLinks) {
|
|
155
|
+
pkg.dependencies["@capacitor/app"] = CAPACITOR_APP_VERSION
|
|
156
|
+
}
|
|
157
|
+
if (app.nativeUpdate) {
|
|
158
|
+
pkg.dependencies["@inglorious/supabase"] = "workspace:*"
|
|
159
|
+
}
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
function catalogue(app, language) {
|
|
@@ -148,6 +165,7 @@ function catalogue(app, language) {
|
|
|
148
165
|
...LANGUAGES[language].texts,
|
|
149
166
|
...(app.analytics && { analytics }),
|
|
150
167
|
...(app.push && { push }),
|
|
168
|
+
...(app.nativeUpdate && { nativeUpdate: NATIVE_UPDATE_TEXTS[language] }),
|
|
151
169
|
}
|
|
152
170
|
}
|
|
153
171
|
|
|
@@ -195,12 +213,27 @@ function mainSource(app) {
|
|
|
195
213
|
if (app.hasPrompts) {
|
|
196
214
|
imports.push('import { renderPrompts } from "./common/prompt"')
|
|
197
215
|
}
|
|
216
|
+
if (app.nativeUpdate || app.appLinks) {
|
|
217
|
+
imports.push('import { App as NativeApp } from "@capacitor/app"')
|
|
218
|
+
}
|
|
219
|
+
if (app.appLinks) {
|
|
220
|
+
imports.push('import { watchAppLinks } from "@inglorious/web/app-links"')
|
|
221
|
+
}
|
|
222
|
+
if (app.nativeUpdate) {
|
|
223
|
+
imports.push(
|
|
224
|
+
'import { when } from "@inglorious/web/directives/when"',
|
|
225
|
+
'import { watchNativeUpdate } from "@inglorious/web/native-update"',
|
|
226
|
+
)
|
|
227
|
+
if (app.isTypeScript) imports.push('import type { AppApi } from "../types"')
|
|
228
|
+
}
|
|
198
229
|
|
|
199
230
|
const args = [
|
|
200
231
|
"store",
|
|
201
|
-
app.
|
|
202
|
-
? "
|
|
203
|
-
:
|
|
232
|
+
app.nativeUpdate
|
|
233
|
+
? "renderApp"
|
|
234
|
+
: app.hasPrompts
|
|
235
|
+
? "(api) => html`${App.render(null, api)} ${renderPrompts(api)}`"
|
|
236
|
+
: "(api) => App.render(null, api)",
|
|
204
237
|
app.isTypeScript
|
|
205
238
|
? 'document.getElementById("root")!'
|
|
206
239
|
: 'document.getElementById("root")',
|
|
@@ -209,9 +242,7 @@ function mainSource(app) {
|
|
|
209
242
|
const body = app.i18n
|
|
210
243
|
? `// Mounts once the starting language has loaded, so the first render is
|
|
211
244
|
// already translated.
|
|
212
|
-
|
|
213
|
-
${call("mount", args, " ", ",")}
|
|
214
|
-
)`
|
|
245
|
+
${afterReady(args)}`
|
|
215
246
|
: call("mount", args, "", "")
|
|
216
247
|
|
|
217
248
|
const session = app.push
|
|
@@ -222,12 +253,41 @@ supabase.auth.onAuthStateChange((_event, session) => {
|
|
|
222
253
|
})`
|
|
223
254
|
: ""
|
|
224
255
|
|
|
256
|
+
const appLinks = app.appLinks
|
|
257
|
+
? `
|
|
258
|
+
|
|
259
|
+
watchAppLinks(NativeApp, store)`
|
|
260
|
+
: ""
|
|
261
|
+
|
|
262
|
+
const nativeUpdate = app.nativeUpdate
|
|
263
|
+
? `
|
|
264
|
+
|
|
265
|
+
watchNativeUpdate(NativeApp, store)
|
|
266
|
+
|
|
267
|
+
// An outdated native build shows nothing but the update page.
|
|
268
|
+
function renderApp(api${app.isTypeScript ? ": AppApi" : ""}) {
|
|
269
|
+
return html\`\${when(
|
|
270
|
+
api.getEntity("nativeUpdate")${app.isTypeScript ? "!" : ""}.isUpdateRequired,
|
|
271
|
+
() => api.render("nativeUpdate"),
|
|
272
|
+
() => html\`\${App.render(null, api)} \${renderPrompts(api)}\`,
|
|
273
|
+
)}\`
|
|
274
|
+
}`
|
|
275
|
+
: ""
|
|
276
|
+
|
|
225
277
|
return `${importBlock(imports)}
|
|
226
278
|
|
|
227
|
-
${body}${session}
|
|
279
|
+
${body}${session}${appLinks}${nativeUpdate}
|
|
228
280
|
`
|
|
229
281
|
}
|
|
230
282
|
|
|
283
|
+
function afterReady(args) {
|
|
284
|
+
const oneLine = `ready.then(() => ${call("mount", args, "", "")})`
|
|
285
|
+
if (!oneLine.includes("\n") && oneLine.length <= LINE_WIDTH) return oneLine
|
|
286
|
+
return `ready.then(() =>
|
|
287
|
+
${call("mount", args, " ", ",")}
|
|
288
|
+
)`
|
|
289
|
+
}
|
|
290
|
+
|
|
231
291
|
function uiComponents(app) {
|
|
232
292
|
const names = ["app-bar", "container"]
|
|
233
293
|
if (app.links) names.push("link")
|
|
@@ -244,6 +304,18 @@ body {
|
|
|
244
304
|
}
|
|
245
305
|
`
|
|
246
306
|
|
|
307
|
+
const UPDATE_PAGE = `
|
|
308
|
+
.app-update {
|
|
309
|
+
display: flex;
|
|
310
|
+
flex-direction: column;
|
|
311
|
+
align-items: center;
|
|
312
|
+
justify-content: center;
|
|
313
|
+
gap: 1rem;
|
|
314
|
+
padding: 1.5rem;
|
|
315
|
+
text-align: center;
|
|
316
|
+
}
|
|
317
|
+
`
|
|
318
|
+
|
|
247
319
|
const PLAIN_PROMPT = `
|
|
248
320
|
.app-prompt {
|
|
249
321
|
position: fixed;
|
|
@@ -287,7 +359,7 @@ main {
|
|
|
287
359
|
align-items: center;
|
|
288
360
|
gap: 1rem;
|
|
289
361
|
}
|
|
290
|
-
${app.tabs && app.hasPrompts ? PROMPT_ABOVE_TABS : ""}`
|
|
362
|
+
${app.tabs && app.hasPrompts ? PROMPT_ABOVE_TABS : ""}${app.nativeUpdate ? UPDATE_PAGE : ""}`
|
|
291
363
|
if (app.ui) {
|
|
292
364
|
return app.isVue
|
|
293
365
|
? `${layout}
|
|
@@ -348,12 +420,18 @@ function storeSource(app) {
|
|
|
348
420
|
`import { PushPrompt } from "${relative("src/store", app.layout.pushPrompt)}"`,
|
|
349
421
|
)
|
|
350
422
|
}
|
|
423
|
+
if (app.nativeUpdate) {
|
|
424
|
+
imports.push(
|
|
425
|
+
`import { NativeUpdate } from "${relative("src/store", app.layout.nativeUpdate)}"`,
|
|
426
|
+
)
|
|
427
|
+
}
|
|
351
428
|
|
|
352
429
|
const types = ["Message", "Home"]
|
|
353
430
|
if (app.routing) types.push("About", "Router")
|
|
354
431
|
if (app.i18n) types.push("I18n")
|
|
355
432
|
if (app.analytics) types.push("Analytics")
|
|
356
433
|
if (app.push) types.push("PushPrompt")
|
|
434
|
+
if (app.nativeUpdate) types.push("NativeUpdate")
|
|
357
435
|
const oneLine = ` types: { ${types.join(", ")} },`
|
|
358
436
|
const typesValue =
|
|
359
437
|
oneLine.length <= LINE_WIDTH
|
|
@@ -381,6 +459,7 @@ function singletons(app) {
|
|
|
381
459
|
if (app.i18n) ids.push("i18n")
|
|
382
460
|
if (app.analytics) ids.push("analytics")
|
|
383
461
|
if (app.push) ids.push("pushPrompt")
|
|
462
|
+
if (app.nativeUpdate) ids.push("nativeUpdate")
|
|
384
463
|
return ids
|
|
385
464
|
}
|
|
386
465
|
|
|
@@ -527,6 +606,22 @@ function typesSource(app) {
|
|
|
527
606
|
)
|
|
528
607
|
state.push(" pushPrompt: AppPushPromptEntity")
|
|
529
608
|
}
|
|
609
|
+
if (app.nativeUpdate) {
|
|
610
|
+
imports.push(
|
|
611
|
+
`import type {
|
|
612
|
+
NativeUpdateEntity,
|
|
613
|
+
NativeUpdateType,
|
|
614
|
+
} from "@inglorious/web/native-update"`,
|
|
615
|
+
)
|
|
616
|
+
eventTypes.push("NativeUpdateType")
|
|
617
|
+
entities.push("AppNativeUpdateEntity")
|
|
618
|
+
declarations.push(
|
|
619
|
+
`export interface AppNativeUpdateEntity extends NativeUpdateEntity {
|
|
620
|
+
type: "NativeUpdate"
|
|
621
|
+
}`,
|
|
622
|
+
)
|
|
623
|
+
state.push(" nativeUpdate: AppNativeUpdateEntity")
|
|
624
|
+
}
|
|
530
625
|
|
|
531
626
|
return `${importBlock(imports)}
|
|
532
627
|
|
package/bin/lib/constants.js
CHANGED
package/bin/lib/features.js
CHANGED
|
@@ -272,3 +272,62 @@ function from(name, source) {
|
|
|
272
272
|
function language(title, texts) {
|
|
273
273
|
return { title, texts }
|
|
274
274
|
}
|
|
275
|
+
|
|
276
|
+
export const NATIVE_UPDATE_TEXTS = {
|
|
277
|
+
en: {
|
|
278
|
+
title: "Time to update",
|
|
279
|
+
body: "This version of the app is no longer supported. Update it to keep going.",
|
|
280
|
+
bodyNoLink:
|
|
281
|
+
"This version of the app is no longer supported. Update it from your app store to keep going.",
|
|
282
|
+
update: "Update",
|
|
283
|
+
},
|
|
284
|
+
it: {
|
|
285
|
+
title: "È ora di aggiornare",
|
|
286
|
+
body: "Questa versione dell'app non è più supportata. Aggiornala per continuare.",
|
|
287
|
+
bodyNoLink:
|
|
288
|
+
"Questa versione dell'app non è più supportata. Aggiornala dal tuo store per continuare.",
|
|
289
|
+
update: "Aggiorna",
|
|
290
|
+
},
|
|
291
|
+
fr: {
|
|
292
|
+
title: "Mise à jour nécessaire",
|
|
293
|
+
body: "Cette version de l'app n'est plus prise en charge. Mettez-la à jour pour continuer.",
|
|
294
|
+
bodyNoLink:
|
|
295
|
+
"Cette version de l'app n'est plus prise en charge. Mettez-la à jour depuis votre store pour continuer.",
|
|
296
|
+
update: "Mettre à jour",
|
|
297
|
+
},
|
|
298
|
+
de: {
|
|
299
|
+
title: "Zeit für ein Update",
|
|
300
|
+
body: "Diese Version der App wird nicht mehr unterstützt. Aktualisiere sie, um weiterzumachen.",
|
|
301
|
+
bodyNoLink:
|
|
302
|
+
"Diese Version der App wird nicht mehr unterstützt. Aktualisiere sie in deinem App Store, um weiterzumachen.",
|
|
303
|
+
update: "Aktualisieren",
|
|
304
|
+
},
|
|
305
|
+
es: {
|
|
306
|
+
title: "Hora de actualizar",
|
|
307
|
+
body: "Esta versión de la app ya no es compatible. Actualízala para continuar.",
|
|
308
|
+
bodyNoLink:
|
|
309
|
+
"Esta versión de la app ya no es compatible. Actualízala desde tu tienda de apps para continuar.",
|
|
310
|
+
update: "Actualizar",
|
|
311
|
+
},
|
|
312
|
+
pt: {
|
|
313
|
+
title: "Hora de atualizar",
|
|
314
|
+
body: "Esta versão da app já não é suportada. Atualiza-a para continuar.",
|
|
315
|
+
bodyNoLink:
|
|
316
|
+
"Esta versão da app já não é suportada. Atualiza-a na tua loja de apps para continuar.",
|
|
317
|
+
update: "Atualizar",
|
|
318
|
+
},
|
|
319
|
+
ja: {
|
|
320
|
+
title: "アップデートが必要です",
|
|
321
|
+
body: "このバージョンのアプリはサポートが終了しました。引き続き使うにはアップデートしてください。",
|
|
322
|
+
bodyNoLink:
|
|
323
|
+
"このバージョンのアプリはサポートが終了しました。引き続き使うにはアプリストアからアップデートしてください。",
|
|
324
|
+
update: "アップデート",
|
|
325
|
+
},
|
|
326
|
+
ar: {
|
|
327
|
+
title: "حان وقت التحديث",
|
|
328
|
+
body: "لم يعد هذا الإصدار من التطبيق مدعومًا. حدّثه للمتابعة.",
|
|
329
|
+
bodyNoLink:
|
|
330
|
+
"لم يعد هذا الإصدار من التطبيق مدعومًا. حدّثه من متجر التطبيقات للمتابعة.",
|
|
331
|
+
update: "تحديث",
|
|
332
|
+
},
|
|
333
|
+
}
|
package/bin/lib/file.js
CHANGED
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
FIRST_ITEM_INDEX,
|
|
9
9
|
} from "./constants.js"
|
|
10
10
|
|
|
11
|
+
const MIGRATION_STAMP_LENGTH = 14
|
|
12
|
+
const ONE_SECOND = 1
|
|
13
|
+
|
|
11
14
|
export function copyDir(src, dest) {
|
|
12
15
|
fs.mkdirSync(dest, { recursive: true })
|
|
13
16
|
|
|
@@ -64,6 +67,28 @@ export function injectAfterImport(source, importSource, newImport) {
|
|
|
64
67
|
)
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Prefixes a migration with the current time, as `supabase migration new`
|
|
72
|
+
* does, a second later than any migration already there.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} targetDir
|
|
75
|
+
* @param {string} name - The file in supabase/migrations.
|
|
76
|
+
*/
|
|
77
|
+
export function stampMigration(targetDir, name) {
|
|
78
|
+
const migrations = path.join(targetDir, "supabase/migrations")
|
|
79
|
+
const taken = new Set(
|
|
80
|
+
fs.readdirSync(migrations).map((file) => file.split("_")[FIRST_ITEM_INDEX]),
|
|
81
|
+
)
|
|
82
|
+
const time = new Date()
|
|
83
|
+
while (taken.has(migrationStamp(time))) {
|
|
84
|
+
time.setSeconds(time.getSeconds() + ONE_SECOND)
|
|
85
|
+
}
|
|
86
|
+
fs.renameSync(
|
|
87
|
+
path.join(migrations, name),
|
|
88
|
+
path.join(migrations, `${migrationStamp(time)}_${name}`),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
67
92
|
export function escapeRegExp(value) {
|
|
68
93
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
69
94
|
}
|
|
@@ -86,3 +111,10 @@ export function toJavaIdentifier(value) {
|
|
|
86
111
|
|
|
87
112
|
return identifier || "app"
|
|
88
113
|
}
|
|
114
|
+
|
|
115
|
+
function migrationStamp(time) {
|
|
116
|
+
return time
|
|
117
|
+
.toISOString()
|
|
118
|
+
.replace(/\D/g, "")
|
|
119
|
+
.slice(FIRST_ITEM_INDEX, MIGRATION_STAMP_LENGTH)
|
|
120
|
+
}
|
package/bin/lib/firebase.js
CHANGED
|
@@ -2,10 +2,8 @@ import fs from "fs"
|
|
|
2
2
|
import { EOL } from "os"
|
|
3
3
|
import path from "path"
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { copyDir } from "./file.js"
|
|
5
|
+
import { copyDir, stampMigration } from "./file.js"
|
|
7
6
|
|
|
8
|
-
const MIGRATION_STAMP_LENGTH = 14
|
|
9
7
|
const FIRST_NUMBER = 1
|
|
10
8
|
|
|
11
9
|
/**
|
|
@@ -27,7 +25,7 @@ export function addFirebaseSupport(targetDir, pkg, options) {
|
|
|
27
25
|
|
|
28
26
|
if (push) {
|
|
29
27
|
copyDir(path.join(templatesRoot, "features/push"), targetDir)
|
|
30
|
-
stampMigration(targetDir)
|
|
28
|
+
stampMigration(targetDir, "push.sql")
|
|
31
29
|
pkg.scripts["vault:push"] = "node scripts/push-vault-secrets.mjs"
|
|
32
30
|
keepSupabaseOutOfTypeScript(targetDir)
|
|
33
31
|
}
|
|
@@ -63,18 +61,6 @@ function replaceInFile(file, search, replacement) {
|
|
|
63
61
|
)
|
|
64
62
|
}
|
|
65
63
|
|
|
66
|
-
function stampMigration(targetDir) {
|
|
67
|
-
const migrations = path.join(targetDir, "supabase/migrations")
|
|
68
|
-
const stamp = new Date()
|
|
69
|
-
.toISOString()
|
|
70
|
-
.replace(/\D/g, "")
|
|
71
|
-
.slice(FIRST_ITEM_INDEX, MIGRATION_STAMP_LENGTH)
|
|
72
|
-
fs.renameSync(
|
|
73
|
-
path.join(migrations, "push.sql"),
|
|
74
|
-
path.join(migrations, `${stamp}_push.sql`),
|
|
75
|
-
)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
64
|
function appendSetupToReadme(targetDir, { analytics, push, mobile }) {
|
|
79
65
|
const readmePath = path.join(targetDir, "README.md")
|
|
80
66
|
if (!fs.existsSync(readmePath)) return
|
package/bin/lib/mobile.js
CHANGED
|
@@ -2,7 +2,11 @@ import fs from "fs"
|
|
|
2
2
|
import { EOL } from "os"
|
|
3
3
|
import path from "path"
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
CAPACITOR_VERSION,
|
|
7
|
+
INDENTATION,
|
|
8
|
+
TYPESCRIPT_VERSION,
|
|
9
|
+
} from "./constants.js"
|
|
6
10
|
import { copyDir, toJavaIdentifier } from "./file.js"
|
|
7
11
|
|
|
8
12
|
export function addMobileSupport(targetDir, pkg, projectName, templatesRoot) {
|
|
@@ -34,6 +38,7 @@ export function addMobileSupport(targetDir, pkg, projectName, templatesRoot) {
|
|
|
34
38
|
path.join(targetDir, "capacitor.config.ts"),
|
|
35
39
|
createCapacitorConfigSource(projectName),
|
|
36
40
|
)
|
|
41
|
+
writeAppSiteAssociation(targetDir, projectName)
|
|
37
42
|
includeInNodeProject(targetDir, "capacitor.config.ts")
|
|
38
43
|
|
|
39
44
|
appendMobileSetupToReadme(targetDir)
|
|
@@ -107,6 +112,27 @@ pnpm mobile:android
|
|
|
107
112
|
pnpm mobile:ios
|
|
108
113
|
\`\`\`
|
|
109
114
|
|
|
115
|
+
### Opening links in the app
|
|
116
|
+
|
|
117
|
+
With Universal Links, a link to the app's domain opens the installed app instead of the browser, and the router goes to the link's path. The app's side is done; three steps are yours:
|
|
118
|
+
|
|
119
|
+
1. Replace \`TEAM_ID\` in \`public/.well-known/apple-app-site-association\` with your Apple Developer Team ID, and keep the bundle ID in step with \`appId\` in \`capacitor.config.ts\`. Add paths that must stay in the browser as \`{ "/": "/path*", "exclude": true }\` before the last entry.
|
|
120
|
+
2. In Xcode, select the app target, open **Signing & Capabilities**, add **Associated Domains**, and enter \`applinks:your-domain.com\`.
|
|
121
|
+
3. Serve the file as JSON, without a redirect. On Vercel, add this to \`vercel.json\`:
|
|
122
|
+
|
|
123
|
+
\`\`\`json
|
|
124
|
+
{
|
|
125
|
+
"headers": [
|
|
126
|
+
{
|
|
127
|
+
"source": "/.well-known/apple-app-site-association",
|
|
128
|
+
"headers": [{ "key": "Content-Type", "value": "application/json" }]
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
\`\`\`
|
|
133
|
+
|
|
134
|
+
Test on a device by tapping a link in Notes or Messages; typing it in Safari does not count. Apple caches the file, so a change can take a few hours to arrive. Android App Links need \`/.well-known/assetlinks.json\` with your signing certificate's SHA-256 fingerprint and an intent filter in \`AndroidManifest.xml\`; see https://developer.android.com/training/app-links.
|
|
135
|
+
|
|
110
136
|
### Releasing
|
|
111
137
|
|
|
112
138
|
The version users see comes from \`package.json\`, the same on the web, Android and iOS. The build number is shared by both native apps and must go up with every store upload.
|
|
@@ -123,7 +149,7 @@ The version users see comes from \`package.json\`, the same on the web, Android
|
|
|
123
149
|
}
|
|
124
150
|
|
|
125
151
|
export function createCapacitorConfigSource(projectName) {
|
|
126
|
-
const appId =
|
|
152
|
+
const appId = appIdOf(projectName)
|
|
127
153
|
|
|
128
154
|
return `import type { CapacitorConfig } from "@capacitor/cli"
|
|
129
155
|
|
|
@@ -137,6 +163,31 @@ export default config
|
|
|
137
163
|
`
|
|
138
164
|
}
|
|
139
165
|
|
|
166
|
+
function appIdOf(projectName) {
|
|
167
|
+
return `com.inglorious.${toJavaIdentifier(projectName)}`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Links carrying a session token, from sign-up and password-reset emails,
|
|
171
|
+
// stay in the browser, where the sign-in flow expects them.
|
|
172
|
+
function writeAppSiteAssociation(targetDir, projectName) {
|
|
173
|
+
const association = {
|
|
174
|
+
applinks: {
|
|
175
|
+
details: [
|
|
176
|
+
{
|
|
177
|
+
appIDs: [`TEAM_ID.${appIdOf(projectName)}`],
|
|
178
|
+
components: [{ "#": "*access_token*", exclude: true }, { "/": "*" }],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
const dir = path.join(targetDir, "public", ".well-known")
|
|
184
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
185
|
+
fs.writeFileSync(
|
|
186
|
+
path.join(dir, "apple-app-site-association"),
|
|
187
|
+
JSON.stringify(association, null, INDENTATION) + EOL,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
140
191
|
function includeInNodeProject(targetDir, file) {
|
|
141
192
|
const tsconfigPath = path.join(targetDir, "tsconfig.node.json")
|
|
142
193
|
if (!fs.existsSync(tsconfigPath)) return
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import { EOL } from "os"
|
|
3
|
+
import path from "path"
|
|
4
|
+
|
|
5
|
+
import { copyDir, stampMigration } from "./file.js"
|
|
6
|
+
import { importBlock } from "./imports.js"
|
|
7
|
+
import { relative } from "./source.js"
|
|
8
|
+
|
|
9
|
+
const README_SECTION = `## Mandatory Native Updates
|
|
10
|
+
|
|
11
|
+
The native apps carry their web code inside the binary, so only the stores update them. Each platform's row in the \`app_versions\` table holds the oldest build allowed to run: an older one shows nothing but an update page, at launch and whenever it comes back to the foreground.
|
|
12
|
+
|
|
13
|
+
Once a new build is live in a store, raise that platform's minimum to its build number:
|
|
14
|
+
|
|
15
|
+
\`\`\`sql
|
|
16
|
+
update public.app_versions set min_build = 7 where platform = 'android';
|
|
17
|
+
\`\`\`
|
|
18
|
+
|
|
19
|
+
Never before: users would be blocked with nothing to download. Set \`store_url\` to the store page, or the update page shows no button. A failed check keeps the last answer, so nobody is blocked for being offline.
|
|
20
|
+
|
|
21
|
+
Older builds keep talking to the backend until they are blocked, so a change that breaks them ships in two releases: a build that works with both backends, made mandatory, then the breaking change.`
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The source of the NativeUpdate type: the page that blocks a native build
|
|
25
|
+
* older than the minimum in Supabase. Lit-html in every syntax, like the
|
|
26
|
+
* prompts: src/main renders it in place of the app.
|
|
27
|
+
*
|
|
28
|
+
* @param {{ ui: boolean, i18n: boolean, ext: string, isTypeScript: boolean, layout: Record<string, string> }} app
|
|
29
|
+
* @returns {Record<string, string>} Sources by file path.
|
|
30
|
+
*/
|
|
31
|
+
export function nativeUpdateFiles(app) {
|
|
32
|
+
return {
|
|
33
|
+
[`${app.layout.nativeUpdate}/index.${app.ext}`]: nativeUpdateSource(app),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Adds what mandatory native updates need outside src/: the app_versions
|
|
39
|
+
* table and how to raise its minimum in the README.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} targetDir
|
|
42
|
+
* @param {string} templatesRoot
|
|
43
|
+
*/
|
|
44
|
+
export function addNativeUpdateSupport(targetDir, templatesRoot) {
|
|
45
|
+
copyDir(path.join(templatesRoot, "features/native-update"), targetDir)
|
|
46
|
+
stampMigration(targetDir, "native_update.sql")
|
|
47
|
+
appendToReadme(targetDir)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nativeUpdateSource(app) {
|
|
51
|
+
const at = app.layout.nativeUpdate
|
|
52
|
+
const imports = [
|
|
53
|
+
'import { supabaseNativeUpdate } from "@inglorious/supabase"',
|
|
54
|
+
'import { html } from "@inglorious/web"',
|
|
55
|
+
'import { when } from "@inglorious/web/directives/when"',
|
|
56
|
+
'import { createNativeUpdate } from "@inglorious/web/native-update"',
|
|
57
|
+
`import { supabase } from "${relative(at, "src/api/supabase")}"`,
|
|
58
|
+
]
|
|
59
|
+
if (app.ui) imports.push('import { Button } from "@inglorious/ui/button"')
|
|
60
|
+
if (app.i18n) imports.push(`import { t } from "${relative(at, "src/i18n")}"`)
|
|
61
|
+
if (app.isTypeScript) {
|
|
62
|
+
imports.push(
|
|
63
|
+
`import type { AppApi, AppNativeUpdateEntity } from "${relative(at, "types")}"`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
const label = (key, text) =>
|
|
67
|
+
app.i18n ? `t("nativeUpdate.${key}")` : JSON.stringify(text)
|
|
68
|
+
const text = (key, plain) =>
|
|
69
|
+
app.i18n ? `\${t("nativeUpdate.${key}")}` : plain
|
|
70
|
+
const params = app.isTypeScript
|
|
71
|
+
? "entity: AppNativeUpdateEntity, api: AppApi"
|
|
72
|
+
: "entity, api"
|
|
73
|
+
const button = app.ui
|
|
74
|
+
? `\${when(entity.storeUrl, () =>
|
|
75
|
+
Button.render({
|
|
76
|
+
children: ${label("update", "Update")},
|
|
77
|
+
color: "primary",
|
|
78
|
+
onClick: () => api.notify(\`#\${entity.id}:nativeUpdateApply\`),
|
|
79
|
+
}),
|
|
80
|
+
)}`
|
|
81
|
+
: `\${when(
|
|
82
|
+
entity.storeUrl,
|
|
83
|
+
() =>
|
|
84
|
+
html\`<button
|
|
85
|
+
@click=\${() => api.notify(\`#\${entity.id}:nativeUpdateApply\`)}
|
|
86
|
+
>
|
|
87
|
+
${text("update", "Update")}
|
|
88
|
+
</button>\`,
|
|
89
|
+
)}`
|
|
90
|
+
|
|
91
|
+
return `${importBlock(imports)}
|
|
92
|
+
|
|
93
|
+
export const NativeUpdate = {
|
|
94
|
+
...createNativeUpdate({ adapter: supabaseNativeUpdate(supabase) }),
|
|
95
|
+
|
|
96
|
+
render(${params}) {
|
|
97
|
+
const body = entity.storeUrl
|
|
98
|
+
? ${label("body", "This version of the app is no longer supported. Update it to keep going.")}
|
|
99
|
+
: ${label("bodyNoLink", "This version of the app is no longer supported. Update it from your app store to keep going.")}
|
|
100
|
+
|
|
101
|
+
return html\`<main class="app-update">
|
|
102
|
+
<h1>${text("title", "Time to update")}</h1>
|
|
103
|
+
<p>\${body}</p>
|
|
104
|
+
${button}
|
|
105
|
+
</main>\`
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function appendToReadme(targetDir) {
|
|
112
|
+
const readmePath = path.join(targetDir, "README.md")
|
|
113
|
+
if (!fs.existsSync(readmePath)) return
|
|
114
|
+
const readme = fs.readFileSync(readmePath, "utf-8")
|
|
115
|
+
fs.writeFileSync(
|
|
116
|
+
readmePath,
|
|
117
|
+
`${readme.trimEnd()}${EOL}${EOL}${README_SECTION}${EOL}`,
|
|
118
|
+
)
|
|
119
|
+
}
|
package/bin/lib/skills.js
CHANGED
package/bin/lib/source.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/create-app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "A scaffolding tool to quickly create a new app with Inglorious Web.",
|
|
5
5
|
"author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- The oldest native build allowed to run on each platform. Readable before
|
|
2
|
+
-- sign-in, so an outdated app is stopped on its login screen too.
|
|
3
|
+
create table public.app_versions (
|
|
4
|
+
platform text primary key check (platform in ('ios', 'android')),
|
|
5
|
+
min_build integer not null check (min_build >= 1),
|
|
6
|
+
store_url text
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
alter table public.app_versions enable row level security;
|
|
10
|
+
|
|
11
|
+
create policy "Anyone reads the minimum app versions"
|
|
12
|
+
on public.app_versions for select to anon, authenticated
|
|
13
|
+
using (true);
|
|
14
|
+
|
|
15
|
+
insert into public.app_versions (platform, min_build) values
|
|
16
|
+
('ios', 1),
|
|
17
|
+
('android', 1);
|