@zaaxch/tailframe 3.0.0 → 4.0.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/bin/tailframe.mjs +18 -6
- package/package.json +1 -1
- package/src/architecture.mjs +3 -3
- package/src/config.mjs +57 -23
- package/src/conventions.mjs +3 -6
- package/src/exceptions.mjs +16 -6
- package/src/generate.mjs +13 -4
- package/src/new.mjs +133 -165
- package/src/owned-guidance.mjs +20 -8
- package/src/owned-sources.mjs +9 -266
- package/src/sync.mjs +48 -41
- package/src/ui-templates.mjs +0 -45
- package/src/validate.mjs +66 -20
- package/src/flutter.mjs +0 -111
package/src/owned-guidance.mjs
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
export const OWNED_GUIDANCE_START = "<!-- tailframe:owned:start -->";
|
|
2
2
|
export const OWNED_GUIDANCE_END = "<!-- tailframe:owned:end -->";
|
|
3
3
|
|
|
4
|
-
export function
|
|
5
|
-
const
|
|
6
|
-
const client = config.kind !== "service";
|
|
4
|
+
export function productGuidance(config) {
|
|
5
|
+
const apps = config.apps.map((app) => `\`${app.kind}\` at \`${app.path}\` (${app.profiles.join(", ") || "no optional profiles"})`).join("; ");
|
|
7
6
|
return `${OWNED_GUIDANCE_START}
|
|
8
|
-
## Tailframe-owned contract
|
|
7
|
+
## Tailframe-owned product contract
|
|
9
8
|
|
|
10
|
-
- Contract: \`${config.contractVersion}\`; kind:
|
|
11
|
-
-
|
|
9
|
+
- Contract: \`${config.contractVersion}\`; kind: \`product\`.
|
|
10
|
+
- Applications: ${apps}.
|
|
11
|
+
- Run \`pnpm sync:architecture\` and \`pnpm validate:architecture\` from the product root after architectural changes.
|
|
12
|
+
- The product root is the only Git, package-manager, lockfile, Tailframe-manifest, and agent-context boundary.
|
|
12
13
|
- Files written by \`tailframe sync --write\` are generated sources. Change their canonical templates in Tailframe, not in this product.
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
${OWNED_GUIDANCE_END}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function appGuidance(productConfig, app) {
|
|
18
|
+
const profileList = app.profiles.length ? app.profiles.join(", ") : "none";
|
|
19
|
+
const client = app.kind !== "service";
|
|
20
|
+
return `${OWNED_GUIDANCE_START}
|
|
21
|
+
## Tailframe-owned application contract
|
|
22
|
+
|
|
23
|
+
- Product contract: \`${productConfig.contractVersion}\`; kind: \`${app.kind}\`; path: \`${app.path}\`; profiles: ${profileList}.
|
|
24
|
+
- Run product-level validation and sync from the repository root; use \`tailframe validate --app ${app.kind}\` for a focused architecture check.
|
|
25
|
+
- Keep product capabilities under \`src/modules/<module>\`; keep application assembly, technology-neutral contracts, and provider adapters in their canonical app/core/platform roots.
|
|
26
|
+
${client ? "- Notifications use the Tailframe-owned application-shell queue and host. Product copy remains in the owning capability module.\n" : "- Every use case exposes \`execute(context, input)\`. Entry points construct a request or system context and pass an explicit input object.\n- Resident processes perform no DDL. \`schema:apply\` is the only schema lifecycle entry point.\n- RPC operations use \`<owning-module>.<operation>\` with no compatibility aliases.\n"}${OWNED_GUIDANCE_END}`;
|
|
15
27
|
}
|
package/src/owned-sources.mjs
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
useCaseSource
|
|
16
16
|
} from "./service-templates.mjs";
|
|
17
17
|
import {
|
|
18
|
-
extensionAuthStoreSource,
|
|
19
18
|
notificationHostSource,
|
|
20
19
|
notificationStoreSource,
|
|
21
20
|
uiErrorMessagesSource,
|
|
@@ -179,247 +178,8 @@ describe("notification shell", () => {
|
|
|
179
178
|
});
|
|
180
179
|
`;
|
|
181
180
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
unauthenticated,
|
|
185
|
-
forbidden,
|
|
186
|
-
notFound,
|
|
187
|
-
conflict,
|
|
188
|
-
unprocessable,
|
|
189
|
-
rateLimited,
|
|
190
|
-
unavailable,
|
|
191
|
-
internal,
|
|
192
|
-
network,
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
class ServiceException implements Exception {
|
|
196
|
-
const ServiceException({
|
|
197
|
-
required this.code,
|
|
198
|
-
required this.message,
|
|
199
|
-
required this.kind,
|
|
200
|
-
this.statusCode,
|
|
201
|
-
this.details,
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
final String code;
|
|
205
|
-
final String message;
|
|
206
|
-
final FailureKind kind;
|
|
207
|
-
final int? statusCode;
|
|
208
|
-
final Object? details;
|
|
209
|
-
|
|
210
|
-
@override
|
|
211
|
-
String toString() => message;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
FailureKind failureKindForStatus(int? statusCode) {
|
|
215
|
-
if (statusCode == null) return FailureKind.network;
|
|
216
|
-
if (statusCode == 401) return FailureKind.unauthenticated;
|
|
217
|
-
if (statusCode == 403) return FailureKind.forbidden;
|
|
218
|
-
if (statusCode == 404) return FailureKind.notFound;
|
|
219
|
-
if (statusCode == 409) return FailureKind.conflict;
|
|
220
|
-
if (statusCode == 422) return FailureKind.unprocessable;
|
|
221
|
-
if (statusCode == 429) return FailureKind.rateLimited;
|
|
222
|
-
if (statusCode == 503) return FailureKind.unavailable;
|
|
223
|
-
if (statusCode >= 500) return FailureKind.internal;
|
|
224
|
-
return FailureKind.invalid;
|
|
225
|
-
}
|
|
226
|
-
`;
|
|
227
|
-
|
|
228
|
-
const flutterRpcSource = `typedef JsonObject = Map<String, dynamic>;
|
|
229
|
-
|
|
230
|
-
JsonObject? rpcErrorFrom(Object? value) {
|
|
231
|
-
if (value is! JsonObject) return null;
|
|
232
|
-
final error = value['error'];
|
|
233
|
-
return error is JsonObject ? error : null;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
T rpcResult<T>(Object? value) {
|
|
237
|
-
if (value is! JsonObject || !value.containsKey('result')) {
|
|
238
|
-
throw const FormatException('The server returned an invalid response.');
|
|
239
|
-
}
|
|
240
|
-
return value['result'] as T;
|
|
241
|
-
}
|
|
242
|
-
`;
|
|
243
|
-
|
|
244
|
-
const flutterNotificationSource = `import 'dart:async';
|
|
245
|
-
|
|
246
|
-
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
247
|
-
|
|
248
|
-
enum NotificationKind { info, success, warning, error }
|
|
249
|
-
|
|
250
|
-
class AppNotification {
|
|
251
|
-
const AppNotification({
|
|
252
|
-
required this.id,
|
|
253
|
-
required this.message,
|
|
254
|
-
required this.kind,
|
|
255
|
-
required this.duration,
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
final int id;
|
|
259
|
-
final String message;
|
|
260
|
-
final NotificationKind kind;
|
|
261
|
-
final Duration duration;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
class NotificationState {
|
|
265
|
-
const NotificationState(this.queue);
|
|
266
|
-
|
|
267
|
-
final List<AppNotification> queue;
|
|
268
|
-
AppNotification? get active => queue.isEmpty ? null : queue.first;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
class NotificationNotifier extends Notifier<NotificationState> {
|
|
272
|
-
Timer? _timer;
|
|
273
|
-
int _nextId = 1;
|
|
274
|
-
|
|
275
|
-
@override
|
|
276
|
-
NotificationState build() => const NotificationState([]);
|
|
277
|
-
|
|
278
|
-
void notify(
|
|
279
|
-
String message, {
|
|
280
|
-
NotificationKind kind = NotificationKind.info,
|
|
281
|
-
Duration duration = const Duration(seconds: 4),
|
|
282
|
-
}) {
|
|
283
|
-
final notification = AppNotification(
|
|
284
|
-
id: _nextId++,
|
|
285
|
-
message: message,
|
|
286
|
-
kind: kind,
|
|
287
|
-
duration: duration,
|
|
288
|
-
);
|
|
289
|
-
final wasEmpty = state.queue.isEmpty;
|
|
290
|
-
state = NotificationState([...state.queue, notification]);
|
|
291
|
-
if (wasEmpty) _scheduleActive();
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
void dismiss(int id) {
|
|
295
|
-
final wasActive = state.active?.id == id;
|
|
296
|
-
state = NotificationState(
|
|
297
|
-
state.queue.where((notification) => notification.id != id).toList(),
|
|
298
|
-
);
|
|
299
|
-
if (wasActive) _scheduleActive();
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
void clear() {
|
|
303
|
-
_timer?.cancel();
|
|
304
|
-
_timer = null;
|
|
305
|
-
state = const NotificationState([]);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
void _scheduleActive() {
|
|
309
|
-
_timer?.cancel();
|
|
310
|
-
_timer = null;
|
|
311
|
-
final notification = state.active;
|
|
312
|
-
if (notification != null && notification.duration > Duration.zero) {
|
|
313
|
-
_timer = Timer(notification.duration, () => dismiss(notification.id));
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
final notificationProvider =
|
|
319
|
-
NotifierProvider<NotificationNotifier, NotificationState>(
|
|
320
|
-
NotificationNotifier.new,
|
|
321
|
-
);
|
|
322
|
-
`;
|
|
323
|
-
|
|
324
|
-
const flutterNotificationHostSource = `import 'package:flutter/material.dart';
|
|
325
|
-
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
326
|
-
|
|
327
|
-
import '../state/notification_notifier.dart';
|
|
328
|
-
|
|
329
|
-
class NotificationHost extends ConsumerWidget {
|
|
330
|
-
const NotificationHost({super.key});
|
|
331
|
-
|
|
332
|
-
@override
|
|
333
|
-
Widget build(BuildContext context, WidgetRef ref) {
|
|
334
|
-
final notification = ref.watch(notificationProvider).active;
|
|
335
|
-
if (notification == null) return const SizedBox.shrink();
|
|
336
|
-
final colors = Theme.of(context).colorScheme;
|
|
337
|
-
final background = switch (notification.kind) {
|
|
338
|
-
NotificationKind.info => colors.primary,
|
|
339
|
-
NotificationKind.success => Colors.green.shade800,
|
|
340
|
-
NotificationKind.warning => Colors.orange.shade900,
|
|
341
|
-
NotificationKind.error => colors.error,
|
|
342
|
-
};
|
|
343
|
-
return Semantics(
|
|
344
|
-
liveRegion: true,
|
|
345
|
-
child: Material(
|
|
346
|
-
color: background,
|
|
347
|
-
borderRadius: BorderRadius.circular(12),
|
|
348
|
-
elevation: 6,
|
|
349
|
-
child: Padding(
|
|
350
|
-
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
351
|
-
child: Row(
|
|
352
|
-
mainAxisSize: MainAxisSize.min,
|
|
353
|
-
children: [
|
|
354
|
-
Flexible(
|
|
355
|
-
child: Text(
|
|
356
|
-
notification.message,
|
|
357
|
-
style: const TextStyle(color: Colors.white),
|
|
358
|
-
),
|
|
359
|
-
),
|
|
360
|
-
IconButton(
|
|
361
|
-
tooltip: 'Dismiss notification',
|
|
362
|
-
color: Colors.white,
|
|
363
|
-
onPressed: () => ref
|
|
364
|
-
.read(notificationProvider.notifier)
|
|
365
|
-
.dismiss(notification.id),
|
|
366
|
-
icon: const Icon(Icons.close),
|
|
367
|
-
),
|
|
368
|
-
],
|
|
369
|
-
),
|
|
370
|
-
),
|
|
371
|
-
),
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
`;
|
|
376
|
-
|
|
377
|
-
const flutterNotificationsPublicSource = `export '../state/notification_notifier.dart'
|
|
378
|
-
show
|
|
379
|
-
NotificationKind,
|
|
380
|
-
NotificationNotifier,
|
|
381
|
-
NotificationState,
|
|
382
|
-
notificationProvider;
|
|
383
|
-
`;
|
|
384
|
-
|
|
385
|
-
const flutterNotificationTestSource = (packageName) => `import 'package:${packageName}/app/public/notifications.dart';
|
|
386
|
-
import 'package:flutter_test/flutter_test.dart';
|
|
387
|
-
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
388
|
-
|
|
389
|
-
void main() {
|
|
390
|
-
testWidgets('orders, dismisses, times, and clears notifications', (
|
|
391
|
-
tester,
|
|
392
|
-
) async {
|
|
393
|
-
final container = ProviderContainer();
|
|
394
|
-
addTearDown(container.dispose);
|
|
395
|
-
final notifier = container.read(notificationProvider.notifier);
|
|
396
|
-
|
|
397
|
-
notifier.notify(
|
|
398
|
-
'first',
|
|
399
|
-
kind: NotificationKind.warning,
|
|
400
|
-
duration: const Duration(milliseconds: 10),
|
|
401
|
-
);
|
|
402
|
-
notifier.notify(
|
|
403
|
-
'second',
|
|
404
|
-
kind: NotificationKind.success,
|
|
405
|
-
duration: Duration.zero,
|
|
406
|
-
);
|
|
407
|
-
expect(container.read(notificationProvider).active?.message, 'first');
|
|
408
|
-
|
|
409
|
-
await tester.pump(const Duration(milliseconds: 11));
|
|
410
|
-
expect(container.read(notificationProvider).active?.message, 'second');
|
|
411
|
-
final second = container.read(notificationProvider).active!;
|
|
412
|
-
notifier.dismiss(second.id);
|
|
413
|
-
expect(container.read(notificationProvider).active, isNull);
|
|
414
|
-
|
|
415
|
-
notifier.notify('third', duration: Duration.zero);
|
|
416
|
-
notifier.clear();
|
|
417
|
-
expect(container.read(notificationProvider).queue, isEmpty);
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
`;
|
|
421
|
-
|
|
422
|
-
export function ownedSources(config, { flutterPackageName } = {}) {
|
|
181
|
+
export function ownedSources(config) {
|
|
182
|
+
if (!["service", "ui"].includes(config.kind)) throw new Error("Tailframe 4 owns only service and UI sources");
|
|
423
183
|
const profiles = new Set(config.profiles);
|
|
424
184
|
if (config.kind === "service") {
|
|
425
185
|
const files = {
|
|
@@ -447,33 +207,16 @@ export function ownedSources(config, { flutterPackageName } = {}) {
|
|
|
447
207
|
if (profiles.has("mongo")) files["deploy/mongo/10-create-users.js"] = mongoUsersSource;
|
|
448
208
|
return files;
|
|
449
209
|
}
|
|
450
|
-
if (config.kind === "ui" || config.kind === "extension") {
|
|
451
|
-
const files = {
|
|
452
|
-
"src/core/errors.ts": uiErrorsSource,
|
|
453
|
-
"src/core/rpc.ts": uiRpcSource,
|
|
454
|
-
"src/platform/errors.ts": uiErrorMessagesSource,
|
|
455
|
-
"src/platform/http.ts": uiHttpSource
|
|
456
|
-
};
|
|
457
|
-
if (config.kind === "extension" && profiles.has("firebase")) {
|
|
458
|
-
files["src/app/stores/auth.store.ts"] = extensionAuthStoreSource;
|
|
459
|
-
}
|
|
460
|
-
if (profiles.has("notifications")) {
|
|
461
|
-
files["src/app/stores/notification.store.ts"] = notificationStoreSource;
|
|
462
|
-
files["src/app/components/NotificationHost.vue"] = notificationHostSource;
|
|
463
|
-
files["src/app/__tests__/notificationStore.test.ts"] = notificationTestSource;
|
|
464
|
-
}
|
|
465
|
-
return files;
|
|
466
|
-
}
|
|
467
210
|
const files = {
|
|
468
|
-
"
|
|
469
|
-
"
|
|
211
|
+
"src/core/errors.ts": uiErrorsSource,
|
|
212
|
+
"src/core/rpc.ts": uiRpcSource,
|
|
213
|
+
"src/platform/errors.ts": uiErrorMessagesSource,
|
|
214
|
+
"src/platform/http.ts": uiHttpSource
|
|
470
215
|
};
|
|
471
216
|
if (profiles.has("notifications")) {
|
|
472
|
-
|
|
473
|
-
files["
|
|
474
|
-
files["
|
|
475
|
-
files["lib/app/public/notifications.dart"] = flutterNotificationsPublicSource;
|
|
476
|
-
files["test/notification_notifier_test.dart"] = flutterNotificationTestSource(flutterPackageName);
|
|
217
|
+
files["src/app/stores/notification.store.ts"] = notificationStoreSource;
|
|
218
|
+
files["src/app/components/NotificationHost.vue"] = notificationHostSource;
|
|
219
|
+
files["src/app/__tests__/notificationStore.test.ts"] = notificationTestSource;
|
|
477
220
|
}
|
|
478
221
|
return files;
|
|
479
222
|
}
|
package/src/sync.mjs
CHANGED
|
@@ -1,26 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { loadConfig } from "./config.mjs";
|
|
4
|
-
import {
|
|
5
|
-
import { OWNED_GUIDANCE_END, OWNED_GUIDANCE_START, ownedGuidance } from "./owned-guidance.mjs";
|
|
3
|
+
import { appRoot, loadConfig } from "./config.mjs";
|
|
4
|
+
import { appGuidance, OWNED_GUIDANCE_END, OWNED_GUIDANCE_START, productGuidance } from "./owned-guidance.mjs";
|
|
6
5
|
import { ownedSources } from "./owned-sources.mjs";
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
if (loaded.errors.length) return { changed: [], errors: loaded.errors };
|
|
11
|
-
if (loaded.config.contractVersion !== runningVersion) {
|
|
12
|
-
return { changed: [], errors: [`tailframe.json requires ${loaded.config.contractVersion}, but this CLI is ${runningVersion}`] };
|
|
13
|
-
}
|
|
14
|
-
const changed = [];
|
|
15
|
-
const errors = [];
|
|
16
|
-
const sourceOptions = {};
|
|
17
|
-
if (loaded.config.kind === "flutter") {
|
|
18
|
-
const packageName = readFlutterPackageName(loaded.root);
|
|
19
|
-
if (!packageName) return { changed, errors: ["pubspec.yaml must declare a package name"] };
|
|
20
|
-
sourceOptions.flutterPackageName = packageName;
|
|
21
|
-
}
|
|
22
|
-
const guidanceFile = path.join(loaded.root, "AGENTS.md");
|
|
23
|
-
const guidance = ownedGuidance(loaded.config);
|
|
7
|
+
function syncGuidance(productRoot, root, guidance, mode, changed, errors, label) {
|
|
8
|
+
const guidanceFile = path.join(root, "AGENTS.md");
|
|
24
9
|
const existingGuidance = fs.existsSync(guidanceFile) ? fs.readFileSync(guidanceFile, "utf8") : "";
|
|
25
10
|
const start = existingGuidance.indexOf(OWNED_GUIDANCE_START);
|
|
26
11
|
const end = existingGuidance.indexOf(OWNED_GUIDANCE_END);
|
|
@@ -28,29 +13,51 @@ export function runSync(rootArgument, mode, runningVersion) {
|
|
|
28
13
|
const currentSection = validSentinels
|
|
29
14
|
? existingGuidance.slice(start, end + OWNED_GUIDANCE_END.length)
|
|
30
15
|
: undefined;
|
|
31
|
-
if (currentSection
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
16
|
+
if (currentSection === guidance) return;
|
|
17
|
+
if (mode === "check") {
|
|
18
|
+
errors.push(`${label} AGENTS.md Tailframe-owned section differs from the canonical guidance`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
let appendix = validSentinels
|
|
22
|
+
? `${existingGuidance.slice(0, start)}${existingGuidance.slice(end + OWNED_GUIDANCE_END.length)}`.trim()
|
|
23
|
+
: existingGuidance.trim();
|
|
24
|
+
appendix = appendix.replace(/^## Product-specific appendix\s*/u, "");
|
|
25
|
+
const next = appendix
|
|
26
|
+
? `${guidance}\n\n## Product-specific appendix\n\n${appendix}\n`
|
|
27
|
+
: `${guidance}\n`;
|
|
28
|
+
fs.mkdirSync(root, { recursive: true });
|
|
29
|
+
fs.writeFileSync(guidanceFile, next);
|
|
30
|
+
changed.push(path.relative(productRoot, guidanceFile));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function runSync(rootArgument, mode, runningVersion, selectedKind) {
|
|
34
|
+
const loaded = loadConfig(rootArgument);
|
|
35
|
+
if (loaded.errors.length) return { changed: [], errors: loaded.errors };
|
|
36
|
+
if (loaded.config.contractVersion !== runningVersion) {
|
|
37
|
+
return { changed: [], errors: [`tailframe.json requires ${loaded.config.contractVersion}, but this CLI is ${runningVersion}`] };
|
|
44
38
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
39
|
+
const changed = [];
|
|
40
|
+
const errors = [];
|
|
41
|
+
syncGuidance(loaded.root, loaded.root, productGuidance(loaded.config), mode, changed, errors, "root");
|
|
42
|
+
|
|
43
|
+
for (const app of loaded.config.apps.filter((candidate) => !selectedKind || candidate.kind === selectedKind)) {
|
|
44
|
+
const root = appRoot(loaded.root, app);
|
|
45
|
+
if (!fs.existsSync(root)) {
|
|
46
|
+
errors.push(`Missing configured application root: ${app.path}`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
syncGuidance(loaded.root, root, appGuidance(loaded.config, app), mode, changed, errors, app.path);
|
|
50
|
+
for (const [relative, expected] of Object.entries(ownedSources(app))) {
|
|
51
|
+
const absolute = path.join(root, relative);
|
|
52
|
+
const productRelative = path.join(app.path, relative);
|
|
53
|
+
const actual = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : undefined;
|
|
54
|
+
if (actual === expected) continue;
|
|
55
|
+
if (mode === "check") errors.push(`${productRelative} differs from the Tailframe ${runningVersion} canonical source`);
|
|
56
|
+
else {
|
|
57
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
58
|
+
fs.writeFileSync(absolute, expected);
|
|
59
|
+
changed.push(productRelative);
|
|
60
|
+
}
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
63
|
return { changed, errors };
|
package/src/ui-templates.mjs
CHANGED
|
@@ -232,51 +232,6 @@ onBeforeUnmount(() => {
|
|
|
232
232
|
</style>
|
|
233
233
|
`;
|
|
234
234
|
|
|
235
|
-
export const extensionAuthStoreSource = `import { auth, signInWithGoogleAccessToken } from "@/platform/firebase";
|
|
236
|
-
import { invalidateGoogleAccessToken, requestGoogleAccessToken, signOutGoogleSession } from "@/platform/extensionAuth";
|
|
237
|
-
import { onIdTokenChanged, signOut, type User } from "firebase/auth/web-extension";
|
|
238
|
-
import { defineStore } from "pinia";
|
|
239
|
-
import { ref } from "vue";
|
|
240
|
-
|
|
241
|
-
export const useAuthStore = defineStore("auth", () => {
|
|
242
|
-
const firebaseUser = ref<User | null>(null);
|
|
243
|
-
const ready = ref(false);
|
|
244
|
-
|
|
245
|
-
onIdTokenChanged(auth, (value) => {
|
|
246
|
-
firebaseUser.value = value;
|
|
247
|
-
ready.value = true;
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
async function signInWithGoogle(interactive = true) {
|
|
251
|
-
let accessToken = await requestGoogleAccessToken(interactive);
|
|
252
|
-
try {
|
|
253
|
-
return await signInWithGoogleAccessToken(accessToken);
|
|
254
|
-
} catch (reason: unknown) {
|
|
255
|
-
if (
|
|
256
|
-
!interactive ||
|
|
257
|
-
typeof reason !== "object" ||
|
|
258
|
-
reason === null ||
|
|
259
|
-
!("code" in reason) ||
|
|
260
|
-
reason.code !== "auth/invalid-credential"
|
|
261
|
-
)
|
|
262
|
-
throw reason;
|
|
263
|
-
await invalidateGoogleAccessToken(accessToken);
|
|
264
|
-
accessToken = await requestGoogleAccessToken(true);
|
|
265
|
-
return signInWithGoogleAccessToken(accessToken);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async function logout() {
|
|
270
|
-
await signOut(auth);
|
|
271
|
-
await signOutGoogleSession();
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const getIdToken = () => firebaseUser.value?.getIdToken();
|
|
275
|
-
|
|
276
|
-
return { firebaseUser, ready, signInWithGoogle, logout, getIdToken };
|
|
277
|
-
});
|
|
278
|
-
`;
|
|
279
|
-
|
|
280
235
|
export const configureHttpSource = `import type { Pinia } from "pinia";
|
|
281
236
|
import router from "@/app/router";
|
|
282
237
|
import { useAuthStore } from "@/app/stores/auth.store";
|
package/src/validate.mjs
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { validateArchitecture } from "./architecture.mjs";
|
|
4
|
+
import { appRoot, loadConfig } from "./config.mjs";
|
|
4
5
|
import { validateConventions } from "./conventions.mjs";
|
|
5
|
-
import { loadConfig } from "./config.mjs";
|
|
6
6
|
import { isExcepted, loadExceptions } from "./exceptions.mjs";
|
|
7
|
-
import { validateFlutter } from "./flutter.mjs";
|
|
8
7
|
import { runSync } from "./sync.mjs";
|
|
9
8
|
|
|
10
9
|
const NON_EXCEPTABLE_RULES = new Set(["S8", "S9", "U5", "U6", "U7"]);
|
|
10
|
+
const PNPM_VERSION = "11.22.0";
|
|
11
11
|
|
|
12
12
|
export function runValidate(root, kind) {
|
|
13
|
-
if (kind === "flutter") return validateFlutter(root);
|
|
14
13
|
const structural = validateArchitecture(root, kind);
|
|
15
14
|
if (structural.some((error) => error.startsWith("Architecture root") || error.startsWith("Architecture kind"))) {
|
|
16
15
|
return structural;
|
|
@@ -22,40 +21,87 @@ export function runValidate(root, kind) {
|
|
|
22
21
|
return [...structural, ...conventions];
|
|
23
22
|
}
|
|
24
23
|
|
|
24
|
+
function escapeRegex(value) {
|
|
25
|
+
return value.replace(/[.*+?^$()|[\]\\{}]/g, "\\$&");
|
|
26
|
+
}
|
|
27
|
+
|
|
25
28
|
function validateVersionMetadata(root, runningVersion) {
|
|
26
29
|
const errors = [];
|
|
27
30
|
const packageFile = path.join(root, "package.json");
|
|
28
|
-
const lockFile = path.join(root, "
|
|
29
|
-
|
|
31
|
+
const lockFile = path.join(root, "pnpm-lock.yaml");
|
|
32
|
+
const workspaceFile = path.join(root, "pnpm-workspace.yaml");
|
|
33
|
+
if (!fs.existsSync(packageFile)) return ["root package.json is required to pin the Tailframe build-time contract"];
|
|
30
34
|
let manifest;
|
|
31
35
|
try { manifest = JSON.parse(fs.readFileSync(packageFile, "utf8")); }
|
|
32
|
-
catch { return ["package.json is not valid JSON"]; }
|
|
36
|
+
catch { return ["root package.json is not valid JSON"]; }
|
|
37
|
+
if (manifest.private !== true) errors.push("root package.json must be private");
|
|
38
|
+
if (manifest.packageManager !== `pnpm@${PNPM_VERSION}`) {
|
|
39
|
+
errors.push(`root package.json packageManager must be pnpm@${PNPM_VERSION}`);
|
|
40
|
+
}
|
|
33
41
|
if (manifest.devDependencies?.["@zaaxch/tailframe"] !== runningVersion) {
|
|
34
|
-
errors.push(`package.json must pin @zaaxch/tailframe exactly to ${runningVersion}`);
|
|
35
|
-
}
|
|
36
|
-
if (!fs.existsSync(
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
errors.push(`root package.json must pin @zaaxch/tailframe exactly to ${runningVersion}`);
|
|
43
|
+
}
|
|
44
|
+
if (!fs.existsSync(workspaceFile)) errors.push("pnpm-workspace.yaml is required at the product root");
|
|
45
|
+
else {
|
|
46
|
+
const workspace = fs.readFileSync(workspaceFile, "utf8");
|
|
47
|
+
if (!/^\s*-\s*["']?apps\/\*["']?\s*$/mu.test(workspace)) {
|
|
48
|
+
errors.push("pnpm-workspace.yaml must include apps/*");
|
|
49
|
+
}
|
|
50
|
+
if (!/^injectWorkspacePackages:\s*true\s*$/mu.test(workspace)) {
|
|
51
|
+
errors.push("pnpm-workspace.yaml must enable injectWorkspacePackages");
|
|
52
|
+
}
|
|
45
53
|
}
|
|
54
|
+
if (!fs.existsSync(lockFile)) return [...errors, "pnpm-lock.yaml is required to lock the Tailframe contract"];
|
|
55
|
+
const lock = fs.readFileSync(lockFile, "utf8");
|
|
56
|
+
const version = escapeRegex(runningVersion);
|
|
57
|
+
const pin = new RegExp(`['"]?@zaaxch/tailframe['"]?:\\s*\\n\\s*specifier:\\s*['"]?${version}['"]?\\s*\\n\\s*version:\\s*['"]?${version}['"]?`, "u");
|
|
58
|
+
const resolution = new RegExp(`['"]?@zaaxch/tailframe@${version}['"]?:`, "u");
|
|
59
|
+
if (!pin.test(lock)) errors.push(`pnpm-lock.yaml root importer must pin @zaaxch/tailframe exactly to ${runningVersion}`);
|
|
60
|
+
if (!resolution.test(lock)) errors.push(`pnpm-lock.yaml does not resolve @zaaxch/tailframe ${runningVersion}`);
|
|
46
61
|
return errors;
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
|
|
64
|
+
function validateAppMetadata(productRoot, app) {
|
|
65
|
+
const errors = [];
|
|
66
|
+
const root = appRoot(productRoot, app);
|
|
67
|
+
if (!fs.existsSync(root)) return [`Missing configured application root: ${app.path}`];
|
|
68
|
+
if (fs.existsSync(path.join(root, "tailframe.json"))) {
|
|
69
|
+
errors.push(`${app.path}/tailframe.json is forbidden; the product root owns Tailframe metadata`);
|
|
70
|
+
}
|
|
71
|
+
if (fs.existsSync(path.join(root, "package-lock.json")) || fs.existsSync(path.join(root, "pnpm-lock.yaml"))) {
|
|
72
|
+
errors.push(`${app.path} must not contain an application lockfile`);
|
|
73
|
+
}
|
|
74
|
+
const packageFile = path.join(root, "package.json");
|
|
75
|
+
if (!fs.existsSync(packageFile)) errors.push(`${app.path}/package.json is required`);
|
|
76
|
+
else {
|
|
77
|
+
try {
|
|
78
|
+
const manifest = JSON.parse(fs.readFileSync(packageFile, "utf8"));
|
|
79
|
+
if (manifest.devDependencies?.["@zaaxch/tailframe"] || manifest.dependencies?.["@zaaxch/tailframe"]) {
|
|
80
|
+
errors.push(`${app.path}/package.json must not depend on @zaaxch/tailframe; pin it at the product root`);
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
errors.push(`${app.path}/package.json is not valid JSON`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return errors;
|
|
87
|
+
}
|
|
88
|
+
export function runConfiguredValidate(rootArgument, runningVersion, selectedKind) {
|
|
50
89
|
const loaded = loadConfig(rootArgument);
|
|
51
90
|
if (loaded.errors.length) return loaded.errors;
|
|
52
91
|
const errors = [];
|
|
53
92
|
if (loaded.config.contractVersion !== runningVersion) {
|
|
54
93
|
errors.push(`tailframe.json requires ${loaded.config.contractVersion}, but this CLI is ${runningVersion}`);
|
|
55
94
|
}
|
|
95
|
+
if (selectedKind && !loaded.config.apps.some((app) => app.kind === selectedKind)) {
|
|
96
|
+
errors.push(`tailframe.json does not declare a ${selectedKind} app`);
|
|
97
|
+
}
|
|
56
98
|
errors.push(...validateVersionMetadata(loaded.root, runningVersion));
|
|
99
|
+
for (const app of loaded.config.apps) errors.push(...validateAppMetadata(loaded.root, app));
|
|
57
100
|
if (errors.length) return errors;
|
|
58
|
-
|
|
59
|
-
|
|
101
|
+
const apps = selectedKind ? loaded.config.apps.filter((app) => app.kind === selectedKind) : loaded.config.apps;
|
|
102
|
+
for (const app of apps) {
|
|
103
|
+
for (const error of runValidate(appRoot(loaded.root, app), app.kind)) errors.push(`${app.path}: ${error}`);
|
|
104
|
+
}
|
|
105
|
+
errors.push(...runSync(loaded.root, "check", runningVersion, selectedKind).errors);
|
|
60
106
|
return errors;
|
|
61
107
|
}
|