@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,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Checks the owner's apps: manifest, generated configuration, the shared SDK
|
|
3
|
+
// version and its shipped app-author reference, declared dependencies, and
|
|
4
|
+
// lint, typecheck, build and tests of every app. The tooling's own checks
|
|
5
|
+
// run in its source repository (check-tooling.mjs).
|
|
6
|
+
// Usage: pnpm run check [--standalone <id|all>]
|
|
7
|
+
import {
|
|
8
|
+
cpSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
import { builtinModules, createRequire } from 'node:module';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { parseArgs } from 'node:util';
|
|
21
|
+
import semver from 'semver';
|
|
22
|
+
import { readApps } from './lib/apps.mjs';
|
|
23
|
+
import { staleGenerated } from './lib/generate.mjs';
|
|
24
|
+
import { migrationState } from './migrate.mjs';
|
|
25
|
+
import { readJson, writeJson } from './lib/json.mjs';
|
|
26
|
+
import { repositoryRoot, TOOLING } from './lib/paths.mjs';
|
|
27
|
+
import { INSTALL, pnpm, scriptArgs } from './lib/pnpm.mjs';
|
|
28
|
+
import { EXACT_VERSION, REFERENCE, SDK } from './lib/sdk.mjs';
|
|
29
|
+
|
|
30
|
+
const SOURCE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
31
|
+
const BUILTIN = new Set(builtinModules);
|
|
32
|
+
|
|
33
|
+
// Build output, installed packages and static files are not app source.
|
|
34
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'dev-dist', 'public']);
|
|
35
|
+
|
|
36
|
+
function sourceFiles(dir) {
|
|
37
|
+
if (!existsSync(dir)) return [];
|
|
38
|
+
return readdirSync(dir).flatMap((name) => {
|
|
39
|
+
const path = join(dir, name);
|
|
40
|
+
if (statSync(path).isDirectory())
|
|
41
|
+
return SKIP_DIRS.has(name) || name.startsWith('.')
|
|
42
|
+
? []
|
|
43
|
+
: sourceFiles(path);
|
|
44
|
+
return SOURCE_EXT.test(name) ? [path] : [];
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let typescript;
|
|
49
|
+
function scanner() {
|
|
50
|
+
// TypeScript's own import scanner: it skips comments and strings and finds
|
|
51
|
+
// static and dynamic imports, re-exports and require() calls. Keep the root
|
|
52
|
+
// dependency on TypeScript 6: TypeScript 7 does not ship this compiler API.
|
|
53
|
+
// Apps can use the native compiler independently of this scanner.
|
|
54
|
+
typescript ??= createRequire(import.meta.url)('typescript');
|
|
55
|
+
return typescript;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Package names imported by a source file (bare specifiers only). */
|
|
59
|
+
export function importedPackages(source) {
|
|
60
|
+
const names = new Set();
|
|
61
|
+
const { importedFiles } = scanner().preProcessFile(source, true, true);
|
|
62
|
+
for (const { fileName: spec } of importedFiles) {
|
|
63
|
+
if (/^(\.|\/|node:|virtual:|[a-z]+:\/\/)/.test(spec)) continue;
|
|
64
|
+
const parts = spec.split('/');
|
|
65
|
+
const name = spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
66
|
+
if (!BUILTIN.has(name)) names.add(name);
|
|
67
|
+
}
|
|
68
|
+
return names;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Problems found without running any tool: manifest, generation, versions,
|
|
73
|
+
* imports and an outstanding starter migration. `tooling` is the installed
|
|
74
|
+
* @sempods/apps.
|
|
75
|
+
*/
|
|
76
|
+
export function staticProblems(root, { tooling = TOOLING } = {}) {
|
|
77
|
+
const problems = [];
|
|
78
|
+
let manifest;
|
|
79
|
+
try {
|
|
80
|
+
manifest = readApps(root);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return [error.message];
|
|
83
|
+
}
|
|
84
|
+
const appsDir = join(root, 'apps');
|
|
85
|
+
// Every directory counts, with or without package.json: an unlisted one
|
|
86
|
+
// would also block new-app for that ID.
|
|
87
|
+
const dirs = existsSync(appsDir)
|
|
88
|
+
? readdirSync(appsDir, { withFileTypes: true })
|
|
89
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
90
|
+
.map((entry) => entry.name)
|
|
91
|
+
: [];
|
|
92
|
+
for (const dir of dirs)
|
|
93
|
+
if (!manifest.apps.some((app) => app.id === dir))
|
|
94
|
+
problems.push(
|
|
95
|
+
`apps/${dir} is not listed in apps.json; add it there or remove the directory`,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const rootManifest = readJson(join(root, 'package.json'));
|
|
99
|
+
|
|
100
|
+
const versions = new Map();
|
|
101
|
+
const expect = (where, pkg, version) => {
|
|
102
|
+
if (!EXACT_VERSION.test(version ?? ''))
|
|
103
|
+
problems.push(
|
|
104
|
+
`${where}: ${pkg} must be an exact version, found ${JSON.stringify(version)}`,
|
|
105
|
+
);
|
|
106
|
+
versions.set(`${where} ${pkg}`, version);
|
|
107
|
+
};
|
|
108
|
+
// The root installs the SDK too, so its shipped reference is there before any
|
|
109
|
+
// app; new-app gives a new app the root's version.
|
|
110
|
+
for (const pkg of SDK)
|
|
111
|
+
expect('root', pkg, rootManifest.devDependencies?.[pkg]);
|
|
112
|
+
|
|
113
|
+
for (const app of manifest.apps) {
|
|
114
|
+
const dir = join(appsDir, app.id);
|
|
115
|
+
if (!existsSync(join(dir, 'package.json'))) {
|
|
116
|
+
problems.push(`apps/${app.id} is listed in apps.json but missing`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const file of staleGenerated(dir, app, manifest.site))
|
|
120
|
+
problems.push(
|
|
121
|
+
`apps/${app.id}/${file} does not match apps.json; regenerate it instead of editing`,
|
|
122
|
+
);
|
|
123
|
+
const pkg = readJson(join(dir, 'package.json'));
|
|
124
|
+
for (const sdk of SDK)
|
|
125
|
+
expect(`apps/${app.id}`, sdk, pkg.dependencies?.[sdk]);
|
|
126
|
+
const declared = new Set([
|
|
127
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
128
|
+
...Object.keys(pkg.devDependencies ?? {}),
|
|
129
|
+
]);
|
|
130
|
+
for (const file of sourceFiles(dir))
|
|
131
|
+
for (const name of importedPackages(readFileSync(file, 'utf8')))
|
|
132
|
+
if (!declared.has(name))
|
|
133
|
+
problems.push(
|
|
134
|
+
`${file.slice(root.length + 1)} imports ${name}, which apps/${app.id}/package.json does not declare`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const found = new Set(versions.values());
|
|
139
|
+
for (const sdk of SDK) {
|
|
140
|
+
const installed = join(root, 'node_modules', sdk, 'package.json');
|
|
141
|
+
if (existsSync(installed)) found.add(readJson(installed).version);
|
|
142
|
+
}
|
|
143
|
+
if (found.size > 1)
|
|
144
|
+
problems.push(
|
|
145
|
+
`SDK versions differ (${[...found].join(', ')}): the root, all apps and the installed packages must use one version`,
|
|
146
|
+
);
|
|
147
|
+
// The tooling generates code against the SDK, so it names the releases it
|
|
148
|
+
// supports.
|
|
149
|
+
const toolingManifest = readJson(join(tooling, 'package.json'));
|
|
150
|
+
for (const sdk of SDK) {
|
|
151
|
+
const range = toolingManifest.peerDependencies?.[sdk];
|
|
152
|
+
const version = rootManifest.devDependencies?.[sdk];
|
|
153
|
+
if (
|
|
154
|
+
range &&
|
|
155
|
+
EXACT_VERSION.test(version ?? '') &&
|
|
156
|
+
!semver.satisfies(version, range, { includePrerelease: true })
|
|
157
|
+
)
|
|
158
|
+
problems.push(
|
|
159
|
+
`${sdk} ${version} is outside the range ${toolingManifest.name} ${toolingManifest.version} supports (${range}); update ${toolingManifest.name} or use a supported SDK version`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
// The app-author reference ships inside the installed SDK.
|
|
163
|
+
if (
|
|
164
|
+
existsSync(join(root, 'node_modules', '@sempods', 'app-sdk')) &&
|
|
165
|
+
!existsSync(join(root, ...REFERENCE.split('/')))
|
|
166
|
+
)
|
|
167
|
+
problems.push(
|
|
168
|
+
`${REFERENCE} is missing: the installed @sempods/app-sdk ships no app-author reference`,
|
|
169
|
+
);
|
|
170
|
+
const migration = migrationState(root, tooling);
|
|
171
|
+
if (migration) problems.push(migration);
|
|
172
|
+
return problems;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const run = (args, cwd) => pnpm(args, { cwd }).status === 0;
|
|
176
|
+
|
|
177
|
+
function standalone(root, id, packageManager) {
|
|
178
|
+
const work = mkdtempSync(join(tmpdir(), `sempods-${id}-`));
|
|
179
|
+
try {
|
|
180
|
+
cpSync(join(root, 'apps', id), work, {
|
|
181
|
+
recursive: true,
|
|
182
|
+
filter: (src) =>
|
|
183
|
+
!/[\\/](node_modules|dist)([\\/]|$)/.test(src.slice(root.length)),
|
|
184
|
+
});
|
|
185
|
+
// The workspace settings (no install scripts, Node version, overrides)
|
|
186
|
+
// apply here too; its package pattern matches nothing in the copy.
|
|
187
|
+
cpSync(join(root, 'pnpm-workspace.yaml'), join(work, 'pnpm-workspace.yaml'));
|
|
188
|
+
// The copy installs with the root's pinned pnpm, not whichever pnpm or
|
|
189
|
+
// Corepack default would serve a manifest without packageManager.
|
|
190
|
+
const manifest = readJson(join(work, 'package.json'));
|
|
191
|
+
manifest.packageManager = packageManager;
|
|
192
|
+
writeJson(join(work, 'package.json'), manifest);
|
|
193
|
+
// Installs, lints, builds and tests without the workspace, so a package that
|
|
194
|
+
// only another app or the root tooling provides fails here.
|
|
195
|
+
return (
|
|
196
|
+
run(INSTALL, work) &&
|
|
197
|
+
run(['run', 'lint'], work) &&
|
|
198
|
+
run(['run', 'build'], work) &&
|
|
199
|
+
run(['run', 'test'], work)
|
|
200
|
+
);
|
|
201
|
+
} finally {
|
|
202
|
+
rmSync(work, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function main() {
|
|
207
|
+
const { values } = parseArgs({
|
|
208
|
+
args: scriptArgs(),
|
|
209
|
+
options: { standalone: { type: 'string' } },
|
|
210
|
+
});
|
|
211
|
+
const root = repositoryRoot();
|
|
212
|
+
const failures = staticProblems(root);
|
|
213
|
+
for (const problem of failures) console.error(`✗ ${problem}`);
|
|
214
|
+
|
|
215
|
+
const step = (label, ok) => {
|
|
216
|
+
console.log(`${ok ? '✓' : '✗'} ${label}`);
|
|
217
|
+
if (!ok) failures.push(label);
|
|
218
|
+
};
|
|
219
|
+
for (const app of readAppsSafe(root)) {
|
|
220
|
+
const dir = join(root, 'apps', app.id);
|
|
221
|
+
step(`apps/${app.id}: lint`, run(['run', 'lint'], dir));
|
|
222
|
+
step(`apps/${app.id}: typecheck and build`, run(['run', 'build'], dir));
|
|
223
|
+
step(`apps/${app.id}: tests`, run(['run', 'test'], dir));
|
|
224
|
+
}
|
|
225
|
+
// Outside the workspace, an app finds only what its own package.json
|
|
226
|
+
// declares, including CLIs its scripts call.
|
|
227
|
+
const apps = readAppsSafe(root).map((app) => app.id);
|
|
228
|
+
const isolated =
|
|
229
|
+
values.standalone === 'all'
|
|
230
|
+
? apps
|
|
231
|
+
: values.standalone
|
|
232
|
+
? [values.standalone]
|
|
233
|
+
: [];
|
|
234
|
+
const { packageManager } = readJson(join(root, 'package.json'));
|
|
235
|
+
for (const id of isolated)
|
|
236
|
+
step(
|
|
237
|
+
`apps/${id}: standalone install, lint, build and tests`,
|
|
238
|
+
apps.includes(id) && standalone(root, id, packageManager),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (failures.length > 0) {
|
|
242
|
+
console.error(`\ncheck failed: ${failures.length} problem(s).`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
console.log('\ncheck passed.');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readAppsSafe(root) {
|
|
249
|
+
try {
|
|
250
|
+
return readApps(root).apps;
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (
|
|
257
|
+
process.argv[1] &&
|
|
258
|
+
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
259
|
+
)
|
|
260
|
+
main();
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Records where the site is published in apps.json and regenerates every app's
|
|
3
|
+
// configuration from it. Each app gets a did:web identity per site origin;
|
|
4
|
+
// local development keeps its own profile.
|
|
5
|
+
// Usage: pnpm run configure-site --production <origin> [--preview <origin> |
|
|
6
|
+
// --no-preview] [--change-domain]
|
|
7
|
+
import { callbackUrl, checkDidWeb } from '@sempods/client-sdk/oauth/host';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { parseArgs } from 'node:util';
|
|
12
|
+
import {
|
|
13
|
+
readApps,
|
|
14
|
+
SITE_PROFILES,
|
|
15
|
+
siteIdentity,
|
|
16
|
+
validateApps,
|
|
17
|
+
writeApps,
|
|
18
|
+
} from './lib/apps.mjs';
|
|
19
|
+
import { writeGenerated } from './lib/generate.mjs';
|
|
20
|
+
import { repositoryRoot } from './lib/paths.mjs';
|
|
21
|
+
import { scriptArgs } from './lib/pnpm.mjs';
|
|
22
|
+
|
|
23
|
+
/** Applies the options to apps.json and regenerates the apps; returns the site. */
|
|
24
|
+
export function configureSite(
|
|
25
|
+
root,
|
|
26
|
+
{ production, preview, noPreview = false, changeDomain = false } = {},
|
|
27
|
+
) {
|
|
28
|
+
const manifest = readApps(root);
|
|
29
|
+
const current = manifest.site;
|
|
30
|
+
if (preview !== undefined && noPreview)
|
|
31
|
+
throw new Error('Use either --preview or --no-preview.');
|
|
32
|
+
// A new origin is a new identity for every app: logins and grants start over.
|
|
33
|
+
if (
|
|
34
|
+
current?.production &&
|
|
35
|
+
production !== undefined &&
|
|
36
|
+
production !== current.production &&
|
|
37
|
+
!changeDomain
|
|
38
|
+
)
|
|
39
|
+
throw new Error(
|
|
40
|
+
`site.production is ${current.production}. Changing it gives every app a new identity, so every login and Pod grant must be made again. Rerun with --change-domain if that is intended.`,
|
|
41
|
+
);
|
|
42
|
+
const nextPreview = noPreview ? undefined : (preview ?? current?.preview);
|
|
43
|
+
const site = {
|
|
44
|
+
production: production ?? current?.production,
|
|
45
|
+
...(nextPreview && { preview: nextPreview }),
|
|
46
|
+
};
|
|
47
|
+
if (site.production === undefined)
|
|
48
|
+
throw new Error('Name the published site: --production <https-origin>.');
|
|
49
|
+
const next = { ...manifest, site };
|
|
50
|
+
const problems = validateApps(next);
|
|
51
|
+
if (problems.length > 0) throw new Error(problems.join('; '));
|
|
52
|
+
// The SDK's own checks, so a site is accepted exactly when the runtime
|
|
53
|
+
// would accept its identities (canonical HTTPS callback, no loopback,
|
|
54
|
+
// did:web matching the callback).
|
|
55
|
+
for (const profile of SITE_PROFILES) {
|
|
56
|
+
if (!site[profile]) continue;
|
|
57
|
+
const { clientId, redirectUri } = siteIdentity(site[profile], {
|
|
58
|
+
id: 'app',
|
|
59
|
+
path: '/app/',
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
checkDidWeb(clientId, callbackUrl(redirectUri, false));
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`site.${profile} ${site[profile]} is not a published address the SDK accepts: use a public HTTPS host, such as https://apps.example.org.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
writeApps(root, next);
|
|
70
|
+
for (const app of next.apps) {
|
|
71
|
+
const dir = join(root, 'apps', app.id);
|
|
72
|
+
if (existsSync(dir)) writeGenerated(dir, app, site);
|
|
73
|
+
}
|
|
74
|
+
return next;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function main() {
|
|
78
|
+
const { values } = parseArgs({
|
|
79
|
+
args: scriptArgs(),
|
|
80
|
+
options: {
|
|
81
|
+
production: { type: 'string' },
|
|
82
|
+
preview: { type: 'string' },
|
|
83
|
+
'no-preview': { type: 'boolean' },
|
|
84
|
+
'change-domain': { type: 'boolean' },
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const root = repositoryRoot();
|
|
88
|
+
const { site, apps } = configureSite(root, {
|
|
89
|
+
production: values.production,
|
|
90
|
+
preview: values.preview,
|
|
91
|
+
noPreview: values['no-preview'],
|
|
92
|
+
changeDomain: values['change-domain'],
|
|
93
|
+
});
|
|
94
|
+
console.log('Site configured.');
|
|
95
|
+
for (const profile of SITE_PROFILES) {
|
|
96
|
+
if (!site[profile]) continue;
|
|
97
|
+
console.log(`\n${profile}: ${site[profile]}`);
|
|
98
|
+
for (const app of apps) {
|
|
99
|
+
const { clientId, redirectUri } = siteIdentity(site[profile], app);
|
|
100
|
+
console.log(` ${app.id}: ${clientId} → ${redirectUri}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
console.log(
|
|
104
|
+
'\nBuild it: pnpm run build-site (see instructions/publish.md in @sempods/apps)',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (
|
|
109
|
+
process.argv[1] &&
|
|
110
|
+
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
111
|
+
) {
|
|
112
|
+
try {
|
|
113
|
+
main();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(`configure-site: ${error.message}`);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Creates a new apps repository from the starter this package ships, records
|
|
3
|
+
// the starter snapshot it applied as the repository's baseline and initializes
|
|
4
|
+
// Git on main, the branch the starter's workflows check.
|
|
5
|
+
// Usage: sempods-apps create <directory> (pnpm create @sempods/apps <dir>)
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { readJson, writeJson } from './lib/json.mjs';
|
|
17
|
+
import { TOOLING } from './lib/paths.mjs';
|
|
18
|
+
import { scriptArgs } from './lib/pnpm.mjs';
|
|
19
|
+
import { starterAt, writeBaseline } from './lib/shared.mjs';
|
|
20
|
+
|
|
21
|
+
const NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Writes the starter of `snapshot` into `target`, which must be missing or
|
|
25
|
+
* empty, with the tooling `version` in its manifest.
|
|
26
|
+
*/
|
|
27
|
+
export function create(target, { snapshot, version }) {
|
|
28
|
+
const dir = resolve(target);
|
|
29
|
+
if (existsSync(dir) && readdirSync(dir).length > 0)
|
|
30
|
+
throw new Error(`${target} already exists and is not empty`);
|
|
31
|
+
for (const file of Object.keys(snapshot.files)) {
|
|
32
|
+
mkdirSync(dirname(join(dir, file)), { recursive: true });
|
|
33
|
+
writeFileSync(join(dir, file), readFileSync(snapshot.path(file)));
|
|
34
|
+
}
|
|
35
|
+
const manifest = readJson(join(dir, 'package.json'));
|
|
36
|
+
const name = basename(dir).toLowerCase();
|
|
37
|
+
if (NAME.test(name)) manifest.name = name;
|
|
38
|
+
manifest.devDependencies['@sempods/apps'] = version;
|
|
39
|
+
writeJson(join(dir, 'package.json'), manifest);
|
|
40
|
+
writeBaseline(dir, { version, revision: snapshot.revision });
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The starter this installed package ships; only a packed package has one. */
|
|
45
|
+
export function packagedStarter() {
|
|
46
|
+
if (!existsSync(join(TOOLING, 'shared', 'snapshot.json')))
|
|
47
|
+
throw new Error(
|
|
48
|
+
'this @sempods/apps has no packed starter; create repositories with a published version, for example pnpm create @sempods/apps <directory>',
|
|
49
|
+
);
|
|
50
|
+
return starterAt(TOOLING);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function main(args = scriptArgs()) {
|
|
54
|
+
if (args.length !== 1 || args[0].startsWith('-'))
|
|
55
|
+
throw new Error('Usage: pnpm create @sempods/apps <directory>');
|
|
56
|
+
const dir = create(args[0], packagedStarter());
|
|
57
|
+
const git = spawnSync('git', ['init', '--quiet', '-b', 'main'], {
|
|
58
|
+
cwd: dir,
|
|
59
|
+
stdio: 'ignore',
|
|
60
|
+
});
|
|
61
|
+
console.log(`Created ${dir}.`);
|
|
62
|
+
if (git.status !== 0)
|
|
63
|
+
console.log(
|
|
64
|
+
'Git is missing or failed: initialize the repository on the main branch before the first commit.',
|
|
65
|
+
);
|
|
66
|
+
// No shell command to copy: open the folder; INIT.md has the setup steps.
|
|
67
|
+
console.log(`
|
|
68
|
+
Open this folder in your coding assistant and paste:
|
|
69
|
+
|
|
70
|
+
Read AGENTS.md and INIT.md. Help me build my first app.`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (
|
|
74
|
+
process.argv[1] &&
|
|
75
|
+
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
76
|
+
) {
|
|
77
|
+
try {
|
|
78
|
+
main();
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(`create: ${error.message}`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/scripts/dev.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Starts the development server of one app. Usage: pnpm run dev <id>
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readApps } from './lib/apps.mjs';
|
|
5
|
+
import { repositoryRoot } from './lib/paths.mjs';
|
|
6
|
+
import { pnpm, scriptArgs } from './lib/pnpm.mjs';
|
|
7
|
+
|
|
8
|
+
const root = repositoryRoot();
|
|
9
|
+
const [id] = scriptArgs();
|
|
10
|
+
const manifest = readApps(root);
|
|
11
|
+
const app = manifest.apps.find((entry) => entry.id === id);
|
|
12
|
+
if (!app) {
|
|
13
|
+
const known = manifest.apps.map((entry) => entry.id).join(', ') || 'none yet';
|
|
14
|
+
console.error(`Usage: pnpm run dev <id>. Apps: ${known}.`);
|
|
15
|
+
process.exit(2);
|
|
16
|
+
}
|
|
17
|
+
console.log(`${app.title}: http://127.0.0.1:${app.devPort}${app.path}`);
|
|
18
|
+
const result = pnpm(['run', 'dev'], { cwd: join(root, 'apps', app.id) });
|
|
19
|
+
process.exit(result.status ?? 1);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Reads and validates apps.json, the owner's manifest of apps.
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readJson, writeJson } from './json.mjs';
|
|
5
|
+
|
|
6
|
+
export const SCHEMA_VERSION = 1;
|
|
7
|
+
export const FIRST_DEV_PORT = 5174;
|
|
8
|
+
export const LANGUAGES = ['en', 'de'];
|
|
9
|
+
// Where the site is published (configure-site): one origin per profile and the
|
|
10
|
+
// host whose routing build-site writes for.
|
|
11
|
+
export const SITE_PROFILES = ['production', 'preview'];
|
|
12
|
+
const SITE_KEYS = new Set(SITE_PROFILES);
|
|
13
|
+
|
|
14
|
+
const ID_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
|
|
15
|
+
// Paths the site itself uses, and directory names that would confuse the
|
|
16
|
+
// repository layout.
|
|
17
|
+
const SITE_RESERVED = new Set([
|
|
18
|
+
'apps',
|
|
19
|
+
'assets',
|
|
20
|
+
'callback',
|
|
21
|
+
'index',
|
|
22
|
+
'node_modules',
|
|
23
|
+
'public',
|
|
24
|
+
'reference',
|
|
25
|
+
'src',
|
|
26
|
+
'static',
|
|
27
|
+
]);
|
|
28
|
+
// Windows reserves these device names, with or without an extension.
|
|
29
|
+
const WINDOWS_DEVICES = new Set([
|
|
30
|
+
'con',
|
|
31
|
+
'prn',
|
|
32
|
+
'aux',
|
|
33
|
+
'nul',
|
|
34
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
35
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** Returns a reason when `id` cannot name an app, otherwise undefined. */
|
|
39
|
+
export function invalidId(id) {
|
|
40
|
+
if (typeof id !== 'string' || !ID_PATTERN.test(id))
|
|
41
|
+
return 'an app ID is one lowercase segment: a letter, then up to 39 letters, digits or hyphens';
|
|
42
|
+
if (id.endsWith('-')) return 'an app ID does not end with a hyphen';
|
|
43
|
+
if (SITE_RESERVED.has(id)) return `"${id}" is reserved by the site layout`;
|
|
44
|
+
if (WINDOWS_DEVICES.has(id))
|
|
45
|
+
return `"${id}" is a reserved device name on Windows`;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns a reason when `value` is not an origin as apps.json records it,
|
|
51
|
+
* otherwise undefined. configure-site also lets the SDK check the identity.
|
|
52
|
+
*/
|
|
53
|
+
export function invalidOrigin(value) {
|
|
54
|
+
let url;
|
|
55
|
+
try {
|
|
56
|
+
url = new URL(value);
|
|
57
|
+
} catch {
|
|
58
|
+
url = undefined;
|
|
59
|
+
}
|
|
60
|
+
// The origin round trip rejects a path, query, credentials and uppercase:
|
|
61
|
+
// did:web and the callback derive from exactly this host.
|
|
62
|
+
if (url?.protocol !== 'https:' || url.origin !== value || url.port)
|
|
63
|
+
return 'must be an HTTPS origin without a path or port, such as https://apps.example.org';
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The did:web identity and callback of an app published at `origin`. */
|
|
68
|
+
export function siteIdentity(origin, app) {
|
|
69
|
+
return {
|
|
70
|
+
clientId: `did:web:${new URL(origin).hostname}:${app.id}`,
|
|
71
|
+
redirectUri: `${origin}${app.path}callback`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Lists every problem of the optional site configuration. */
|
|
76
|
+
export function validateSite(site) {
|
|
77
|
+
if (site === undefined) return [];
|
|
78
|
+
if (site === null || typeof site !== 'object' || Array.isArray(site))
|
|
79
|
+
return ['site must be an object'];
|
|
80
|
+
const problems = [];
|
|
81
|
+
for (const key of Object.keys(site))
|
|
82
|
+
if (!SITE_KEYS.has(key)) problems.push(`site.${key} is not a known field`);
|
|
83
|
+
if (site.production === undefined)
|
|
84
|
+
problems.push('site.production is required once site is set');
|
|
85
|
+
for (const profile of SITE_PROFILES) {
|
|
86
|
+
if (site[profile] === undefined) continue;
|
|
87
|
+
const reason = invalidOrigin(site[profile]);
|
|
88
|
+
if (reason) problems.push(`site.${profile} ${reason}`);
|
|
89
|
+
}
|
|
90
|
+
if (site.preview !== undefined && site.preview === site.production)
|
|
91
|
+
problems.push('site.preview must differ from site.production');
|
|
92
|
+
return problems;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function appsFile(root) {
|
|
96
|
+
return join(root, 'apps.json');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Reads apps.json. Throws when it is missing or invalid. */
|
|
100
|
+
export function readApps(root) {
|
|
101
|
+
const file = appsFile(root);
|
|
102
|
+
if (!existsSync(file))
|
|
103
|
+
throw new Error(
|
|
104
|
+
'apps.json is missing; it lists the apps of this repository and must exist, even with no apps',
|
|
105
|
+
);
|
|
106
|
+
const manifest = readJson(file);
|
|
107
|
+
const problems = validateApps(manifest);
|
|
108
|
+
if (problems.length > 0) throw new Error(`apps.json: ${problems.join('; ')}`);
|
|
109
|
+
return manifest;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function writeApps(root, manifest) {
|
|
113
|
+
const apps = [...manifest.apps].sort((a, b) => a.id.localeCompare(b.id));
|
|
114
|
+
writeJson(appsFile(root), { ...manifest, apps });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Lists every problem of a parsed apps.json. */
|
|
118
|
+
export function validateApps(manifest) {
|
|
119
|
+
const problems = [];
|
|
120
|
+
if (manifest?.schemaVersion !== SCHEMA_VERSION)
|
|
121
|
+
problems.push(`schemaVersion must be ${SCHEMA_VERSION}`);
|
|
122
|
+
problems.push(...validateSite(manifest?.site));
|
|
123
|
+
if (!Array.isArray(manifest?.apps))
|
|
124
|
+
return [...problems, 'apps must be a list'];
|
|
125
|
+
const ids = new Set();
|
|
126
|
+
const ports = new Set();
|
|
127
|
+
for (const app of manifest.apps) {
|
|
128
|
+
const reason = invalidId(app?.id);
|
|
129
|
+
if (reason) {
|
|
130
|
+
problems.push(`${JSON.stringify(app?.id)}: ${reason}`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (ids.has(app.id)) problems.push(`${app.id}: listed twice`);
|
|
134
|
+
ids.add(app.id);
|
|
135
|
+
if (typeof app.title !== 'string' || app.title.trim() === '')
|
|
136
|
+
problems.push(`${app.id}: title must be text`);
|
|
137
|
+
if (!LANGUAGES.includes(app.language))
|
|
138
|
+
problems.push(
|
|
139
|
+
`${app.id}: language must be one of ${LANGUAGES.join(', ')}`,
|
|
140
|
+
);
|
|
141
|
+
if (app.path !== `/${app.id}/`)
|
|
142
|
+
problems.push(`${app.id}: path must be "/${app.id}/"`);
|
|
143
|
+
if (
|
|
144
|
+
!Number.isInteger(app.devPort) ||
|
|
145
|
+
app.devPort < 1024 ||
|
|
146
|
+
app.devPort > 65535
|
|
147
|
+
)
|
|
148
|
+
problems.push(`${app.id}: devPort must be a port number from 1024`);
|
|
149
|
+
else if (ports.has(app.devPort))
|
|
150
|
+
problems.push(`${app.id}: devPort ${app.devPort} is used twice`);
|
|
151
|
+
ports.add(app.devPort);
|
|
152
|
+
if (typeof app.pwa !== 'boolean')
|
|
153
|
+
problems.push(`${app.id}: pwa must be true or false`);
|
|
154
|
+
}
|
|
155
|
+
return problems;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The first development port after the ones in use. */
|
|
159
|
+
export function nextDevPort(manifest) {
|
|
160
|
+
const used = new Set(manifest.apps.map((app) => app.devPort));
|
|
161
|
+
let port = FIRST_DEV_PORT;
|
|
162
|
+
while (used.has(port)) port += 1;
|
|
163
|
+
return port;
|
|
164
|
+
}
|