@sempods/apps 0.0.0-stage → 0.7.0-dev.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/CHANGELOG.md +205 -0
- package/LICENSE +19 -0
- package/README.md +50 -2
- package/bin/sempods-apps.mjs +36 -0
- package/instructions/app-workflow.md +210 -0
- package/instructions/publish.md +148 -0
- package/instructions/setup.md +112 -0
- package/package.json +59 -3
- package/scripts/build-site.mjs +181 -0
- package/scripts/check.mjs +260 -0
- package/scripts/configure-site.mjs +118 -0
- package/scripts/create.mjs +83 -0
- package/scripts/dev.mjs +19 -0
- package/scripts/lib/apps.mjs +164 -0
- package/scripts/lib/files.mjs +57 -0
- package/scripts/lib/generate.mjs +175 -0
- package/scripts/lib/html.mjs +3 -0
- package/scripts/lib/json.mjs +42 -0
- package/scripts/lib/merge.mjs +159 -0
- package/scripts/lib/overview.mjs +105 -0
- package/scripts/lib/paths.mjs +33 -0
- package/scripts/lib/pnpm.mjs +30 -0
- package/scripts/lib/sdk.mjs +45 -0
- package/scripts/lib/shared.mjs +221 -0
- package/scripts/migrate.mjs +550 -0
- package/scripts/new-app.mjs +166 -0
- package/scripts/regenerate.mjs +41 -0
- package/scripts/sdk-update.mjs +220 -0
- package/scripts/update.mjs +152 -0
- package/shared/files/0221b70340152ba3cc615342254603b29b89c4d7d944d7b3b3e647873bdcefa0 +11 -0
- package/shared/files/1c29eed0a0297c742ca5814de9081ff8efce3b2da5d9e1bdf90a403873da4aa9 +61 -0
- package/shared/files/31ede0d1a035d8531100ebfd751510c772ab0871309a0b9752a77fb96187eb53 +25 -0
- package/shared/files/393b09ecd18889d9e1fdf2a6df12d1f6c193fc7fcd89ba808ad94df521bad8db +41 -0
- package/shared/files/4660ca1887261ae658c7f587190e2b71ac91d15b1b6c4cf04dec823fdd0da654 +22 -0
- package/shared/files/528d7544990228d6d66fa62423775338fe1716326fc68371fb2665aee86e39b0 +6 -0
- package/shared/files/676e6bf33b44044fb4c62c9fe5441729bd47f2d5077ff904e57a4a1b658859ac +38 -0
- package/shared/files/8b7567319e18694aeca8722107281a760e3100239c6d0f548acb71e1806fa172 +28 -0
- package/shared/files/9945f9bd573e5af8aad2e65cc8b4284fbb1d7e8b96c4efdd42eeb49132fb4327 +4 -0
- package/shared/files/b911698dc3cf3227d34d0ad6727f1c4408791203c9665de99ab959331a1fb07d +1 -0
- package/shared/files/c2a4b026a87f6c8e7473e7fc456c6020b63c7e675087f6b58013ed5163c2f6ee +23 -0
- package/shared/files/d3ca3a13c6524a2ee9382977ddc0539790813225f366306024a58c957a4716ee +11 -0
- package/shared/files/e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e +78 -0
- package/shared/files/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +0 -0
- package/shared/files/f2fb601d5ebc4ca72274ac9d3fcf7b7a96e27958bb92cd065a9165b501ee94d7 +57 -0
- package/shared/files/f9e2544290ee8418484e47194444ee58c2ea675682871ae8a8727d1edb17978e +5 -0
- package/shared/files/ff9f774d9732fdc7d704ef37ddc40ef507a1bab7ee3c1db5b1a6a8f26e6e4b37 +35 -0
- package/shared/snapshot.json +25 -0
- package/skeleton/app/.oxlintrc.json +14 -0
- package/skeleton/app/NOTES.md +19 -0
- package/skeleton/app/index.html +16 -0
- package/skeleton/app/package.json +28 -0
- package/skeleton/app/public/favicon.svg +1 -0
- package/skeleton/app/public/icon-192.png +0 -0
- package/skeleton/app/public/icon-512.png +0 -0
- package/skeleton/app/src/App.tsx +73 -0
- package/skeleton/app/src/NewVersionNotice.tsx +63 -0
- package/skeleton/app/src/index.css +37 -0
- package/skeleton/app/src/main.tsx +18 -0
- package/skeleton/app/src/pwa.ts +18 -0
- package/skeleton/app/src/start.test.ts +15 -0
- package/skeleton/app/src/start.ts +9 -0
- package/skeleton/app/tsconfig.app.json +27 -0
- package/skeleton/app/tsconfig.json +8 -0
- package/skeleton/app/tsconfig.node.json +23 -0
- package/skeleton/app/tsconfig.test.json +10 -0
- package/skeleton/app/vite.config.ts +13 -0
- package/skills/app-workflow/SKILL.md +22 -0
- package/skills/update/SKILL.md +69 -0
- package/update-policy.json +35 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// File operations the migration relies on: reading a file that may be absent
|
|
2
|
+
// or a directory, writing in one step, removing with emptied directories.
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmdirSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
statSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
|
|
17
|
+
// What is at a path, or undefined when nothing is, also below a file.
|
|
18
|
+
function entry(path, stat = statSync) {
|
|
19
|
+
try {
|
|
20
|
+
return stat(path, { throwIfNoEntry: false });
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error.code === 'ENOTDIR') return undefined;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const isFile = (path) => entry(path)?.isFile() ?? false;
|
|
28
|
+
|
|
29
|
+
export const isDirectory = (path) => entry(path)?.isDirectory() ?? false;
|
|
30
|
+
|
|
31
|
+
export const isSymlink = (path) =>
|
|
32
|
+
entry(path, lstatSync)?.isSymbolicLink() ?? false;
|
|
33
|
+
|
|
34
|
+
/** A file's text, or null when there is no file at the path. */
|
|
35
|
+
export const readText = (path) =>
|
|
36
|
+
isFile(path) ? readFileSync(path, 'utf8') : null;
|
|
37
|
+
|
|
38
|
+
/** Writes a file in one step: the old or the complete new content remains. */
|
|
39
|
+
export function replaceFile(path, content) {
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
41
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
42
|
+
// Created anew, so a link left at the temporary name is never followed.
|
|
43
|
+
rmSync(temporary, { force: true });
|
|
44
|
+
writeFileSync(temporary, content, { flag: 'wx' });
|
|
45
|
+
renameSync(temporary, path);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Removes a file and the directories it leaves empty, up to `root`. */
|
|
49
|
+
export function removeFile(root, path) {
|
|
50
|
+
rmSync(path, { force: true });
|
|
51
|
+
for (
|
|
52
|
+
let dir = dirname(path);
|
|
53
|
+
dir !== root && existsSync(dir) && readdirSync(dir).length === 0;
|
|
54
|
+
dir = dirname(dir)
|
|
55
|
+
)
|
|
56
|
+
rmdirSync(dir);
|
|
57
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Generated configuration of one app. App code imports these files and never
|
|
2
|
+
// edits them; new-app writes them, and later commands regenerate them from
|
|
3
|
+
// apps.json, so tooling updates reach existing apps.
|
|
4
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { SITE_PROFILES, siteIdentity } from './apps.mjs';
|
|
7
|
+
|
|
8
|
+
const HEADER = `// Generated from apps.json by @sempods/apps. Do not edit: change apps.json
|
|
9
|
+
// and run pnpm run regenerate.`;
|
|
10
|
+
|
|
11
|
+
export const GENERATED_FILES = [
|
|
12
|
+
'src/sempods.generated.ts',
|
|
13
|
+
'vite.sempods.generated.ts',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const json = (value) => JSON.stringify(value);
|
|
17
|
+
|
|
18
|
+
// build-site selects a published profile through Vite's mode, which only the
|
|
19
|
+
// command line sets; dev, plain builds and tests run the local profile.
|
|
20
|
+
export const PROFILE_MODES = {
|
|
21
|
+
production: 'sempods-production',
|
|
22
|
+
preview: 'sempods-preview',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** A published profile: the did:web identity for one site origin. */
|
|
26
|
+
function publishedProfile(name, origin, app) {
|
|
27
|
+
const { clientId, redirectUri } = siteIdentity(origin, app);
|
|
28
|
+
return `
|
|
29
|
+
// ${name === 'production' ? 'Published site' : 'Fixed preview address'}: ${origin}
|
|
30
|
+
const ${name}: BrowserRuntimeOptions = {
|
|
31
|
+
identity: {
|
|
32
|
+
kind: 'did-web',
|
|
33
|
+
clientId: ${json(clientId)},
|
|
34
|
+
redirectUri: ${json(redirectUri)},
|
|
35
|
+
},
|
|
36
|
+
returnTo: app.basePath,
|
|
37
|
+
};
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runtime settings per profile: local development with a dynamic identity on
|
|
43
|
+
* loopback HTTP, and the did:web identities of the configured site origins.
|
|
44
|
+
*/
|
|
45
|
+
export function runtimeSource(app, site) {
|
|
46
|
+
const published = SITE_PROFILES.filter((name) => site?.[name]);
|
|
47
|
+
const modes = SITE_PROFILES.map(
|
|
48
|
+
(name) => ` ${json(PROFILE_MODES[name])}: ${json(name)},`,
|
|
49
|
+
).join('\n');
|
|
50
|
+
return `${HEADER}
|
|
51
|
+
import type { BrowserRuntimeOptions, Language } from '@sempods/app-sdk';
|
|
52
|
+
|
|
53
|
+
export const app = {
|
|
54
|
+
id: ${json(app.id)},
|
|
55
|
+
title: ${json(app.title)},
|
|
56
|
+
language: ${json(app.language)} as Language,
|
|
57
|
+
basePath: ${json(app.path)},
|
|
58
|
+
pwa: ${app.pwa},
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
// Local development: a dynamic identity, local HTTP only.
|
|
62
|
+
const local: BrowserRuntimeOptions = {
|
|
63
|
+
identity: {
|
|
64
|
+
kind: 'dynamic',
|
|
65
|
+
name: app.title,
|
|
66
|
+
// Read when the runtime is created, not on import, so tests can import
|
|
67
|
+
// this module outside a browser.
|
|
68
|
+
get redirectUri() {
|
|
69
|
+
return \`\${location.origin}${app.path}callback\`;
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
returnTo: app.basePath,
|
|
73
|
+
development: 'loopback-http',
|
|
74
|
+
};
|
|
75
|
+
${published.map((name) => publishedProfile(name, site[name], app)).join('')}
|
|
76
|
+
export const profiles: Readonly<Record<string, BrowserRuntimeOptions>> = {
|
|
77
|
+
${['local', ...published].map((name) => ` ${name},`).join('\n')}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// pnpm run build-site builds with one of these modes; any other mode is local.
|
|
81
|
+
const modes: Readonly<Record<string, string>> = {
|
|
82
|
+
${modes}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const profile: string = modes[import.meta.env?.MODE ?? ''] ?? 'local';
|
|
86
|
+
|
|
87
|
+
function unconfigured(name: string): never {
|
|
88
|
+
throw new Error(
|
|
89
|
+
\`apps.json has no site.\${name}; run pnpm run configure-site\`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const runtimeOptions: BrowserRuntimeOptions =
|
|
94
|
+
profiles[profile] ?? unconfigured(profile);
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Vite base path, development server and PWA settings for this app's path. */
|
|
99
|
+
export function viteSource(app) {
|
|
100
|
+
const p = app.path;
|
|
101
|
+
const escaped = p.replaceAll('/', '\\/');
|
|
102
|
+
const pwa = app.pwa
|
|
103
|
+
? `{
|
|
104
|
+
// The app registers its worker itself (src/main.tsx) and never takes over
|
|
105
|
+
// an open page: an update applies once every window is closed.
|
|
106
|
+
registerType: 'prompt',
|
|
107
|
+
injectRegister: false,
|
|
108
|
+
manifest: {
|
|
109
|
+
id: ${json(p)},
|
|
110
|
+
name: ${json(app.title)},
|
|
111
|
+
short_name: ${json(app.title)},
|
|
112
|
+
lang: ${json(app.language)},
|
|
113
|
+
start_url: ${json(p)},
|
|
114
|
+
scope: ${json(p)},
|
|
115
|
+
display: 'standalone',
|
|
116
|
+
background_color: '#f6faf9',
|
|
117
|
+
theme_color: '#176354',
|
|
118
|
+
icons: [
|
|
119
|
+
{ src: ${json(`${p}icon-192.png`)}, sizes: '192x192', type: 'image/png' },
|
|
120
|
+
{ src: ${json(`${p}icon-512.png`)}, sizes: '512x512', type: 'image/png' },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
workbox: {
|
|
124
|
+
// Precache the built shell only; nothing is cached at runtime.
|
|
125
|
+
globPatterns: ['**/*.{js,css,html,png,svg,webmanifest}'],
|
|
126
|
+
runtimeCaching: [],
|
|
127
|
+
// Serve the cached shell only for this app's start route. The callback,
|
|
128
|
+
// Pod and login pages always go to the network.
|
|
129
|
+
navigateFallback: ${json(`${p}index.html`)},
|
|
130
|
+
navigateFallbackAllowlist: [/^${escaped}$/],
|
|
131
|
+
skipWaiting: false,
|
|
132
|
+
clientsClaim: false,
|
|
133
|
+
cleanupOutdatedCaches: true,
|
|
134
|
+
},
|
|
135
|
+
}`
|
|
136
|
+
: 'null';
|
|
137
|
+
return `${HEADER}
|
|
138
|
+
import type { VitePWAOptions } from 'vite-plugin-pwa';
|
|
139
|
+
|
|
140
|
+
export const base = ${json(p)};
|
|
141
|
+
|
|
142
|
+
export const server = {
|
|
143
|
+
host: '127.0.0.1',
|
|
144
|
+
port: ${app.devPort},
|
|
145
|
+
strictPort: true,
|
|
146
|
+
} as const;
|
|
147
|
+
|
|
148
|
+
export const pwa: Partial<VitePWAOptions> | null = ${pwa};
|
|
149
|
+
`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function generatedFiles(app, site) {
|
|
153
|
+
return {
|
|
154
|
+
'src/sempods.generated.ts': runtimeSource(app, site),
|
|
155
|
+
'vite.sempods.generated.ts': viteSource(app),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function writeGenerated(appDir, app, site) {
|
|
160
|
+
for (const [file, source] of Object.entries(generatedFiles(app, site)))
|
|
161
|
+
writeFileSync(join(appDir, file), source);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Lists generated files whose content differs from what apps.json produces. */
|
|
165
|
+
export function staleGenerated(appDir, app, site) {
|
|
166
|
+
return Object.entries(generatedFiles(app, site))
|
|
167
|
+
.filter(([file, source]) => {
|
|
168
|
+
try {
|
|
169
|
+
return readFileSync(join(appDir, file), 'utf8') !== source;
|
|
170
|
+
} catch {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
.map(([file]) => file);
|
|
175
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Reads and writes JSON files in the format the tooling uses.
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { replaceFile } from './files.mjs';
|
|
4
|
+
|
|
5
|
+
/** Two-space indentation and a final newline. */
|
|
6
|
+
export const formatJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
7
|
+
|
|
8
|
+
export function writeJson(path, value) {
|
|
9
|
+
writeFileSync(path, formatJson(value));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Replaces a JSON file in one step: a reader sees the old or the new content,
|
|
14
|
+
* never a partly written file.
|
|
15
|
+
*/
|
|
16
|
+
export function replaceJson(path, value) {
|
|
17
|
+
replaceFile(path, formatJson(value));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Reads a JSON file and names it when its content is not JSON. */
|
|
21
|
+
export function readJson(path) {
|
|
22
|
+
const text = readFileSync(path, 'utf8');
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(text);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new Error(`${path} is not valid JSON: ${error.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runs a file operation and returns undefined when the file does not exist.
|
|
32
|
+
* Callers act directly instead of checking first, because the file could
|
|
33
|
+
* change between the check and the use.
|
|
34
|
+
*/
|
|
35
|
+
export function ifPresent(operation) {
|
|
36
|
+
try {
|
|
37
|
+
return operation();
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT') return undefined;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Merges a repository's file with a starter change: three-way merges of
|
|
2
|
+
// text, owner sections that stay verbatim, and starter entries of manifests.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
9
|
+
|
|
10
|
+
function git(cwd, args) {
|
|
11
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
12
|
+
if (result.error) throw result.error;
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Replaces one section between two marker lines with another text's section. */
|
|
17
|
+
export function withSection(text, markers, from) {
|
|
18
|
+
const [begin, end] = markers;
|
|
19
|
+
const span = (value) => {
|
|
20
|
+
const start = value.indexOf(begin);
|
|
21
|
+
const stop = value.indexOf(end, start);
|
|
22
|
+
return start < 0 || stop < 0 ? null : [start, stop + end.length];
|
|
23
|
+
};
|
|
24
|
+
const target = span(text);
|
|
25
|
+
const source = span(from);
|
|
26
|
+
if (!target || !source) return null;
|
|
27
|
+
return (
|
|
28
|
+
text.slice(0, target[0]) + from.slice(...source) + text.slice(target[1])
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Three-way merge with git; returns the merged text and its conflict count. */
|
|
33
|
+
export function mergeText(ours, base, theirs, labels) {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), 'sempods-merge-'));
|
|
35
|
+
try {
|
|
36
|
+
const files = ['ours', 'base', 'theirs'].map((name, i) => {
|
|
37
|
+
const path = join(dir, name);
|
|
38
|
+
writeFileSync(path, [ours, base, theirs][i]);
|
|
39
|
+
return path;
|
|
40
|
+
});
|
|
41
|
+
const result = git(dir, [
|
|
42
|
+
'merge-file',
|
|
43
|
+
'-p',
|
|
44
|
+
'-L',
|
|
45
|
+
labels[0],
|
|
46
|
+
'-L',
|
|
47
|
+
labels[1],
|
|
48
|
+
'-L',
|
|
49
|
+
labels[2],
|
|
50
|
+
...files,
|
|
51
|
+
]);
|
|
52
|
+
if (result.status < 0 || result.status > 127)
|
|
53
|
+
throw new Error(`git merge-file failed: ${result.stderr}`);
|
|
54
|
+
return { text: result.stdout, conflicts: result.status };
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decides one starter-owned manifest value. A value equal to the one the old
|
|
62
|
+
* starter shipped follows the new starter; anything else is the owner's and
|
|
63
|
+
* stays, with a note.
|
|
64
|
+
*/
|
|
65
|
+
function decide(ours, base, theirs) {
|
|
66
|
+
if (same(ours, theirs)) return { value: ours };
|
|
67
|
+
// Absence counts as the owner's removal only if the old starter had it.
|
|
68
|
+
if (ours === undefined)
|
|
69
|
+
return base !== undefined
|
|
70
|
+
? { value: undefined, note: 'removed in this repository; left out' }
|
|
71
|
+
: { value: theirs };
|
|
72
|
+
if (same(base, ours)) return { value: theirs };
|
|
73
|
+
return {
|
|
74
|
+
value: ours,
|
|
75
|
+
note: `changed in this repository; kept ${JSON.stringify(ours)}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const KEYED = [
|
|
80
|
+
'scripts',
|
|
81
|
+
'dependencies',
|
|
82
|
+
'devDependencies',
|
|
83
|
+
'engines',
|
|
84
|
+
'overrides',
|
|
85
|
+
];
|
|
86
|
+
const PLAIN = ['type', 'private', 'packageManager', 'workspaces'];
|
|
87
|
+
const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies'];
|
|
88
|
+
|
|
89
|
+
/** Starter entries of a manifest, merged; owner entries and names are untouched. */
|
|
90
|
+
export function mergeManifest(
|
|
91
|
+
ours,
|
|
92
|
+
base,
|
|
93
|
+
theirs,
|
|
94
|
+
sdk,
|
|
95
|
+
sdkVersion,
|
|
96
|
+
notes,
|
|
97
|
+
where,
|
|
98
|
+
) {
|
|
99
|
+
const result = structuredClone(ours);
|
|
100
|
+
const apply = (target, key, decision, label) => {
|
|
101
|
+
if (decision.note) notes.push(`${where} ${label}: ${decision.note}`);
|
|
102
|
+
if (decision.value === undefined) delete target[key];
|
|
103
|
+
else target[key] = decision.value;
|
|
104
|
+
};
|
|
105
|
+
for (const field of PLAIN)
|
|
106
|
+
apply(
|
|
107
|
+
result,
|
|
108
|
+
field,
|
|
109
|
+
decide(ours[field], base[field], theirs[field]),
|
|
110
|
+
field,
|
|
111
|
+
);
|
|
112
|
+
for (const field of KEYED) {
|
|
113
|
+
const keys = new Set([
|
|
114
|
+
...Object.keys(theirs[field] ?? {}),
|
|
115
|
+
...Object.keys(base[field] ?? {}),
|
|
116
|
+
]);
|
|
117
|
+
const target = { ...(result[field] ?? {}) };
|
|
118
|
+
for (const key of keys) {
|
|
119
|
+
if (sdk.includes(key) && field.endsWith('ependencies')) continue;
|
|
120
|
+
apply(
|
|
121
|
+
target,
|
|
122
|
+
key,
|
|
123
|
+
decide(ours[field]?.[key], base[field]?.[key], theirs[field]?.[key]),
|
|
124
|
+
`${field}.${key}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
// The SDK sits where the release puts it; elsewhere only if the release
|
|
128
|
+
// declares it nowhere in this manifest.
|
|
129
|
+
if (field.endsWith('ependencies'))
|
|
130
|
+
for (const key of sdk) {
|
|
131
|
+
const declared = DEPENDENCY_FIELDS.some(
|
|
132
|
+
(f) => theirs[f]?.[key] !== undefined,
|
|
133
|
+
);
|
|
134
|
+
if (
|
|
135
|
+
declared
|
|
136
|
+
? theirs[field]?.[key] !== undefined
|
|
137
|
+
: target[key] !== undefined
|
|
138
|
+
)
|
|
139
|
+
target[key] = sdkVersion;
|
|
140
|
+
else if (target[key] !== undefined) {
|
|
141
|
+
delete target[key];
|
|
142
|
+
notes.push(
|
|
143
|
+
`${where} ${field}.${key}: moved to the section the release declares it in`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Package managers keep dependency lists sorted; scripts and the rest keep their order.
|
|
148
|
+
const ordered = field.endsWith('ependencies')
|
|
149
|
+
? Object.fromEntries(
|
|
150
|
+
Object.entries(target).sort(([a], [b]) =>
|
|
151
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
: target;
|
|
155
|
+
if (Object.keys(ordered).length) result[field] = ordered;
|
|
156
|
+
else delete result[field];
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The site's static overview and not-found pages. They run no script and no
|
|
2
|
+
// SDK runtime; each entry links to its app's own page. The entry shape is the
|
|
3
|
+
// data model a richer app overview can build on.
|
|
4
|
+
import { escapeHtml as escape } from './html.mjs';
|
|
5
|
+
|
|
6
|
+
const TEXT = {
|
|
7
|
+
en: {
|
|
8
|
+
title: 'Apps',
|
|
9
|
+
empty: 'No apps yet.',
|
|
10
|
+
missing: 'Page not found',
|
|
11
|
+
back: 'All apps',
|
|
12
|
+
},
|
|
13
|
+
de: {
|
|
14
|
+
title: 'Apps',
|
|
15
|
+
empty: 'Noch keine Apps.',
|
|
16
|
+
missing: 'Seite nicht gefunden',
|
|
17
|
+
back: 'Alle Apps',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// System colors follow light and dark mode and keep their contrast.
|
|
22
|
+
const STYLE = `
|
|
23
|
+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
|
24
|
+
body { margin: 0; background: Canvas; color: CanvasText; }
|
|
25
|
+
main { max-width: 56rem; margin: 0 auto; padding: 2rem 1rem; }
|
|
26
|
+
h1 { font-size: 1.75rem; margin: 0 0 1.5rem; }
|
|
27
|
+
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 1rem;
|
|
28
|
+
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); }
|
|
29
|
+
a { display: flex; flex-direction: column; align-items: center; gap: 0.75rem;
|
|
30
|
+
padding: 1.25rem 1rem; border: 1px solid GrayText; border-radius: 1rem;
|
|
31
|
+
color: inherit; text-decoration: none; text-align: center; }
|
|
32
|
+
a:hover { border-color: LinkText; }
|
|
33
|
+
a:focus-visible { outline: 3px solid Highlight; outline-offset: 3px; }
|
|
34
|
+
img, .letter { width: 4rem; height: 4rem; border-radius: 1rem; }
|
|
35
|
+
.letter { display: grid; place-items: center; font-size: 2rem;
|
|
36
|
+
font-weight: 600; background: GrayText; color: Canvas; }
|
|
37
|
+
.name { font-weight: 600; }
|
|
38
|
+
.description { font-size: 0.9rem; }
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
function page(language, title, body) {
|
|
42
|
+
return `<!doctype html>
|
|
43
|
+
<html lang="${escape(language)}">
|
|
44
|
+
<head>
|
|
45
|
+
<meta charset="utf-8" />
|
|
46
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
47
|
+
<title>${escape(title)}</title>
|
|
48
|
+
<style>${STYLE}</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<main>
|
|
52
|
+
${body}
|
|
53
|
+
</main>
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function card(entry) {
|
|
60
|
+
// The name follows as text, so the icon is decorative.
|
|
61
|
+
const letter = [...entry.title.trim()][0]?.toUpperCase() ?? '?';
|
|
62
|
+
const icon = entry.icon
|
|
63
|
+
? `<img src="${escape(entry.icon)}" alt="" width="64" height="64" />`
|
|
64
|
+
: `<span class="letter" aria-hidden="true">${escape(letter)}</span>`;
|
|
65
|
+
const description = entry.description
|
|
66
|
+
? `\n <span class="description">${escape(entry.description)}</span>`
|
|
67
|
+
: '';
|
|
68
|
+
return ` <li>
|
|
69
|
+
<a href="${escape(entry.href)}" lang="${escape(entry.language)}">
|
|
70
|
+
${icon}
|
|
71
|
+
<span class="name">${escape(entry.title)}</span>${description}
|
|
72
|
+
</a>
|
|
73
|
+
</li>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One language for a site page: the apps' shared language, else English. */
|
|
77
|
+
export function siteLanguage(entries) {
|
|
78
|
+
const languages = new Set(entries.map((entry) => entry.language));
|
|
79
|
+
const [only] = languages;
|
|
80
|
+
return languages.size === 1 && TEXT[only] ? only : 'en';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The overview: one card per `{ id, title, language, href, icon?,
|
|
85
|
+
* description? }`, in the given order.
|
|
86
|
+
*/
|
|
87
|
+
export function renderOverview(entries) {
|
|
88
|
+
const language = siteLanguage(entries);
|
|
89
|
+
const text = TEXT[language];
|
|
90
|
+
const list = entries.length
|
|
91
|
+
? ` <ul>\n${entries.map(card).join('\n')}\n </ul>`
|
|
92
|
+
: ` <p>${escape(text.empty)}</p>`;
|
|
93
|
+
return page(language, text.title, ` <h1>${escape(text.title)}</h1>\n${list}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The page for unknown paths, so no host falls back to an app or the overview. */
|
|
97
|
+
export function renderNotFound(entries) {
|
|
98
|
+
const language = siteLanguage(entries);
|
|
99
|
+
const text = TEXT[language];
|
|
100
|
+
return page(
|
|
101
|
+
language,
|
|
102
|
+
text.missing,
|
|
103
|
+
` <h1>${escape(text.missing)}</h1>\n <p><a href="/">${escape(text.back)}</a></p>`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The two places the tooling works with: the owner's repository, found from
|
|
2
|
+
// where a command runs, and the tooling's own files, found from this module.
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
/** The tooling directory: scripts, skeleton, instructions and skills. */
|
|
8
|
+
export const TOOLING = resolve(
|
|
9
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
10
|
+
'..',
|
|
11
|
+
'..',
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
/** The skeleton a new app starts from. */
|
|
15
|
+
export const SKELETON = join(TOOLING, 'skeleton', 'app');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The apps repository that contains `start`: the nearest directory with
|
|
19
|
+
* apps.json and package.json. Throws when there is none.
|
|
20
|
+
*/
|
|
21
|
+
export function repositoryRoot(start = process.cwd()) {
|
|
22
|
+
for (let dir = resolve(start); ; dir = dirname(dir)) {
|
|
23
|
+
if (
|
|
24
|
+
existsSync(join(dir, 'apps.json')) &&
|
|
25
|
+
existsSync(join(dir, 'package.json'))
|
|
26
|
+
)
|
|
27
|
+
return dir;
|
|
28
|
+
if (dirname(dir) === dir)
|
|
29
|
+
throw new Error(
|
|
30
|
+
`no apps repository here: no directory from ${resolve(start)} upward has apps.json and package.json`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// pnpm runs every install and package script; packageManager in the root
|
|
2
|
+
// package.json pins its version.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
// pnpm defaults to a frozen lockfile in CI. The commands that add an app or
|
|
6
|
+
// move a dependency must update it, and a standalone copy has none.
|
|
7
|
+
export const INSTALL = ['install', '--no-frozen-lockfile'];
|
|
8
|
+
|
|
9
|
+
// On Windows the shell finds pnpm.cmd but joins the arguments unquoted: pass
|
|
10
|
+
// directories through `cwd`, never as arguments.
|
|
11
|
+
export function pnpm(args, options = {}) {
|
|
12
|
+
return spawnSync('pnpm', args, {
|
|
13
|
+
stdio: 'inherit',
|
|
14
|
+
shell: process.platform === 'win32',
|
|
15
|
+
...options,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Runs a pnpm command that prints JSON; throws with its error output. */
|
|
20
|
+
export function pnpmJson(cwd, args) {
|
|
21
|
+
const result = pnpm(args, { cwd, stdio: 'pipe', encoding: 'utf8' });
|
|
22
|
+
if (result.status !== 0)
|
|
23
|
+
throw new Error(`pnpm ${args.join(' ')} failed: ${result.stderr.trim()}`);
|
|
24
|
+
return JSON.parse(result.stdout);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Script arguments; pnpm passes a leading `--` (npm's habit) through as is. */
|
|
28
|
+
export function scriptArgs(argv = process.argv.slice(2)) {
|
|
29
|
+
return argv[0] === '--' ? argv.slice(1) : argv;
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The SDK packages every app uses, and how their exact versions compare.
|
|
2
|
+
export const SDK = ['@sempods/app-sdk', '@sempods/client-sdk'];
|
|
3
|
+
|
|
4
|
+
/** The app-author reference the installed SDK ships, relative to the root. */
|
|
5
|
+
export const REFERENCE = 'node_modules/@sempods/app-sdk/docs/ai-app-builder.md';
|
|
6
|
+
|
|
7
|
+
// A failed SDK update can leave manifests, lockfile and installed packages at
|
|
8
|
+
// the target already. sdk-update keeps this local checkpoint in the repository
|
|
9
|
+
// root until the entire update passes; tooling updates refuse to start while
|
|
10
|
+
// it exists.
|
|
11
|
+
export const PENDING_UPDATE = '.sdk-update-pending';
|
|
12
|
+
|
|
13
|
+
// SemVer 2.0: numeric identifiers cannot have leading zeroes.
|
|
14
|
+
export const EXACT_VERSION =
|
|
15
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
16
|
+
|
|
17
|
+
export function compareVersions(a, b) {
|
|
18
|
+
const left = EXACT_VERSION.exec(a);
|
|
19
|
+
const right = EXACT_VERSION.exec(b);
|
|
20
|
+
for (const [match, v] of [
|
|
21
|
+
[left, a],
|
|
22
|
+
[right, b],
|
|
23
|
+
])
|
|
24
|
+
if (!match) throw new Error(`not an exact version: ${JSON.stringify(v)}`);
|
|
25
|
+
for (let i = 1; i <= 3; i++) {
|
|
26
|
+
if (BigInt(left[i]) !== BigInt(right[i]))
|
|
27
|
+
return BigInt(left[i]) > BigInt(right[i]) ? 1 : -1;
|
|
28
|
+
}
|
|
29
|
+
if (left[4] === right[4]) return 0;
|
|
30
|
+
if (!left[4]) return 1;
|
|
31
|
+
if (!right[4]) return -1;
|
|
32
|
+
const l = left[4].split('.');
|
|
33
|
+
const r = right[4].split('.');
|
|
34
|
+
for (let i = 0; i < Math.max(l.length, r.length); i++) {
|
|
35
|
+
if (l[i] === r[i]) continue;
|
|
36
|
+
if (l[i] === undefined) return -1;
|
|
37
|
+
if (r[i] === undefined) return 1;
|
|
38
|
+
const ln = /^\d+$/.test(l[i]);
|
|
39
|
+
const rn = /^\d+$/.test(r[i]);
|
|
40
|
+
if (ln && rn) return BigInt(l[i]) > BigInt(r[i]) ? 1 : -1;
|
|
41
|
+
if (ln !== rn) return ln ? -1 : 1;
|
|
42
|
+
return l[i] > r[i] ? 1 : -1;
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|