@ultimat3/cli 24.0.0 → 25.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/CLAUDE.md +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +54 -64
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +64 -85
- package/src/role-sync.ts +65 -6
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +12 -12
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +33 -13
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -15
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +1 -1
- package/src/templates/action.ts +4 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container.ts +12 -6
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
|
@@ -1,19 +1,25 @@
|
|
|
1
|
-
// The CI half of what `x new` writes:
|
|
1
|
+
// The CI half of what `x new` writes: the gate workflow that runs the app's OWN two commands, and
|
|
2
|
+
// the image workflow (`image.yml.ts`) that publishes only what that gate passed.
|
|
2
3
|
//
|
|
3
4
|
// Nothing under `templates/` emitted `.github` until this file, so every scaffolded app started
|
|
4
|
-
// life with a gate that ran on exactly one machine — the author's. The two commands are
|
|
5
|
-
// and `
|
|
6
|
-
// `x db migrate`, `x build` and `x verify` is a second definition of the gate,
|
|
7
|
-
// the scripts a human runs, and axiom 1 has one way to do each thing.
|
|
8
|
-
// synonym for `x verify` — it BUILDS first, and the build is what makes the gate's `budgets` step
|
|
5
|
+
// life with a gate that ran on exactly one machine — the author's. The two commands are
|
|
6
|
+
// `bun run setup` and `bun run check` and NOT a restatement of their steps: a workflow that spelled
|
|
7
|
+
// out `bun install`, `x db migrate`, `x build` and `x verify` is a second definition of the gate,
|
|
8
|
+
// free to drift from the scripts a human runs, and axiom 1 has one way to do each thing.
|
|
9
|
+
// `bun run check` is also not a synonym for `x verify` — it BUILDS first, and the build is what makes the gate's `budgets` step
|
|
9
10
|
// measurable rather than X_BUDGET_UNMEASURED.
|
|
10
11
|
|
|
11
12
|
import { REQUIRED_BUN } from '../../app-root';
|
|
12
13
|
import type { GeneratedFile, NameSet } from '../naming';
|
|
14
|
+
import { IMAGE_WORKFLOW_PATH, imageWorkflow } from './image.yml';
|
|
15
|
+
import { RUNS_ON } from './runner';
|
|
13
16
|
|
|
14
17
|
/** Where the workflow lands. GitHub reads this path and no other. */
|
|
15
18
|
export const CI_WORKFLOW_PATH = '.github/workflows/ci.yml';
|
|
16
19
|
|
|
20
|
+
/** The workflow's `name:` — what `image.yml`'s `workflow_run` trigger waits on, spelled once. */
|
|
21
|
+
const CI_WORKFLOW_NAME = 'ci';
|
|
22
|
+
|
|
17
23
|
/**
|
|
18
24
|
* Pinned by commit SHA with the version in the trailing comment, because this step runs before any
|
|
19
25
|
* code of yours does and `@v2` is a tag its owner can move. `actions/*` follows its major tag
|
|
@@ -22,19 +28,19 @@ export const CI_WORKFLOW_PATH = '.github/workflows/ci.yml';
|
|
|
22
28
|
*/
|
|
23
29
|
const SETUP_BUN = 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0';
|
|
24
30
|
|
|
25
|
-
const ci = (app: NameSet): string => `name:
|
|
31
|
+
const ci = (app: NameSet): string => `name: ${CI_WORKFLOW_NAME}
|
|
26
32
|
|
|
27
33
|
# The gate for ${app.kebab}: the same two commands \`README.md\` tells a human to run, in the same
|
|
28
34
|
# order, on a machine that has never seen this repository. A check that exists only in CI is one
|
|
29
35
|
# nobody can reproduce locally, and a CI file that restates the gate's steps is a second gate.
|
|
30
36
|
#
|
|
31
|
-
# \`
|
|
32
|
-
# the gate's \`budgets\` step compares declared limits against measured bytes in
|
|
37
|
+
# \`bun run check\` is \`x build --target static\` and THEN \`x verify\`. The build is not a
|
|
38
|
+
# convenience: the gate's \`budgets\` step compares declared limits against measured bytes in
|
|
33
39
|
# \`.x/build-stats.json\`, so a run with no build reports X_BUDGET_UNMEASURED. Run \`x verify\` here
|
|
34
40
|
# instead and this workflow is red for a reason that has nothing to do with the commit.
|
|
35
41
|
#
|
|
36
|
-
# No \`services:\` block, deliberately — \`
|
|
37
|
-
# there is nothing to provision and nothing to wait on. Add one the day this app needs a real
|
|
42
|
+
# No \`services:\` block, deliberately — \`bun run setup\` brings up embedded Postgres in-process,
|
|
43
|
+
# so there is nothing to provision and nothing to wait on. Add one the day this app needs a real
|
|
38
44
|
# server, beside the \`DATABASE_URL\` that selects it.
|
|
39
45
|
|
|
40
46
|
# EVERY push, not just the default branch's. \`x new\` runs a plain \`git init\` and takes whatever
|
|
@@ -61,7 +67,8 @@ jobs:
|
|
|
61
67
|
# the push run already gates that exact tree — so the pull_request run is skipped, not paid for
|
|
62
68
|
# twice. A pull request from a FORK fires no push here, and it still runs.
|
|
63
69
|
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
|
64
|
-
|
|
70
|
+
# A free GitHub-hosted runner unless the repository variable \`CI_RUNNER\` names another label.
|
|
71
|
+
runs-on: ${RUNS_ON}
|
|
65
72
|
# A bound on a HANG, not on cost: a step that never returns holds a runner until GitHub's
|
|
66
73
|
# six-hour default expires, and the failure is invisible for all six of them.
|
|
67
74
|
timeout-minutes: 20
|
|
@@ -74,19 +81,22 @@ jobs:
|
|
|
74
81
|
# whoever took the app at its word. Never \`latest\`: a Bun minor landing unannounced is a
|
|
75
82
|
# runtime change nobody chose.
|
|
76
83
|
bun-version: '${REQUIRED_BUN}'
|
|
77
|
-
# Bun's download cache, keyed on the lockfile: \`
|
|
78
|
-
# instead of fetching every package again on every run.
|
|
84
|
+
# Bun's download cache, keyed on the lockfile: \`bun run setup\`'s install then links from
|
|
85
|
+
# disk instead of fetching every package again on every run.
|
|
79
86
|
- uses: actions/cache@v6
|
|
80
87
|
with:
|
|
81
88
|
path: ~/.bun/install/cache
|
|
82
89
|
key: bun-\${{ runner.os }}-\${{ hashFiles('bun.lock') }}
|
|
83
90
|
restore-keys: bun-\${{ runner.os }}-
|
|
84
91
|
# Two steps rather than one \`&&\`, so the log names which half failed and times each.
|
|
85
|
-
- run:
|
|
86
|
-
- run:
|
|
92
|
+
- run: bun run setup
|
|
93
|
+
- run: bun run check
|
|
87
94
|
`;
|
|
88
95
|
|
|
89
|
-
/** The CI a new app is born with, in the order a reader meets it. */
|
|
96
|
+
/** The CI a new app is born with, in the order a reader meets it: the gate, then the image. */
|
|
90
97
|
export function githubFiles(app: NameSet): readonly GeneratedFile[] {
|
|
91
|
-
return [
|
|
98
|
+
return [
|
|
99
|
+
{ path: CI_WORKFLOW_PATH, contents: ci(app) },
|
|
100
|
+
{ path: IMAGE_WORKFLOW_PATH, contents: imageWorkflow(CI_WORKFLOW_NAME) },
|
|
101
|
+
];
|
|
92
102
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The image half of what `x new` writes under `.github/`: publish `docker/Dockerfile` to the
|
|
2
|
+
// repository's own GitHub Container Registry, only for a commit `ci.yml` already passed on the
|
|
3
|
+
// default branch. It runs no gate of its own — a second gate is a second definition of one — and
|
|
4
|
+
// it deploys nothing: the image digest is the handoff, axiom 7's one deploy artifact.
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_RUNNER } from './runner';
|
|
7
|
+
|
|
8
|
+
/** Where the workflow lands, beside the gate it waits on. */
|
|
9
|
+
export const IMAGE_WORKFLOW_PATH = '.github/workflows/image.yml';
|
|
10
|
+
|
|
11
|
+
/** The Dockerfile `containerFiles` writes — the one image every role runs. */
|
|
12
|
+
export const IMAGE_DOCKERFILE = 'docker/Dockerfile';
|
|
13
|
+
|
|
14
|
+
/** `gate` is the `name:` of the workflow whose verdict this one waits on — `ci.yml`'s own. */
|
|
15
|
+
export const imageWorkflow = (gate: string): string => `name: image
|
|
16
|
+
|
|
17
|
+
# Builds ${IMAGE_DOCKERFILE} and pushes it to ghcr.io/<owner>/<repo>, tagged \`sha-<7>\` and the full
|
|
18
|
+
# commit SHA — never \`latest\`, a pointer a late run moves backwards. Only GITHUB_TOKEN: GHCR takes
|
|
19
|
+
# the job's own token, so there is no secret to create.
|
|
20
|
+
#
|
|
21
|
+
# Triggered by \`${gate}\` FINISHING, never by the push both would start from: a second
|
|
22
|
+
# workflow on the same push is not a dependency, and this one must publish only what the gate
|
|
23
|
+
# judged. A \`workflow_run\` has no branch filter here for the reason ci.yml's \`on:\` has none —
|
|
24
|
+
# \`x new\` never decided what the default branch is called — so the job's \`if:\` asks GitHub.
|
|
25
|
+
on:
|
|
26
|
+
workflow_run:
|
|
27
|
+
workflows: [${gate}]
|
|
28
|
+
types: [completed]
|
|
29
|
+
|
|
30
|
+
# One publish per commit, never cancelled mid-push: a half-pushed manifest is worse than a queue.
|
|
31
|
+
concurrency:
|
|
32
|
+
group: image-\${{ github.event.workflow_run.head_sha }}
|
|
33
|
+
cancel-in-progress: false
|
|
34
|
+
|
|
35
|
+
permissions:
|
|
36
|
+
contents: read
|
|
37
|
+
packages: write
|
|
38
|
+
|
|
39
|
+
jobs:
|
|
40
|
+
publish:
|
|
41
|
+
# A green gate, from a push (never a fork's pull request, whose head would build with a
|
|
42
|
+
# registry token in scope), to the default branch of THIS repository.
|
|
43
|
+
if: >-
|
|
44
|
+
github.event.workflow_run.conclusion == 'success' &&
|
|
45
|
+
github.event.workflow_run.event == 'push' &&
|
|
46
|
+
github.event.workflow_run.head_branch == github.event.repository.default_branch &&
|
|
47
|
+
github.event.workflow_run.head_repository.full_name == github.repository
|
|
48
|
+
# NOT \`vars.CI_RUNNER\`: the gate may move to any runner, but this job needs bash (\`\${SHA::7}\`,
|
|
49
|
+
# \`\${GITHUB_REPOSITORY,,}\`) and a Docker daemon, and a Windows or self-hosted label may have
|
|
50
|
+
# neither — a green gate would then publish nothing. The free GitHub-hosted Linux runner has both.
|
|
51
|
+
runs-on: ${DEFAULT_RUNNER}
|
|
52
|
+
defaults:
|
|
53
|
+
run:
|
|
54
|
+
shell: bash
|
|
55
|
+
timeout-minutes: 30
|
|
56
|
+
steps:
|
|
57
|
+
# The commit the gate judged, not the tip of the branch when this run started.
|
|
58
|
+
- uses: actions/checkout@v7
|
|
59
|
+
with:
|
|
60
|
+
ref: \${{ github.event.workflow_run.head_sha }}
|
|
61
|
+
persist-credentials: false
|
|
62
|
+
# GHCR refuses an upper-case repository path, and \`github.repository\` keeps the owner's case.
|
|
63
|
+
- name: name the image
|
|
64
|
+
id: image
|
|
65
|
+
env:
|
|
66
|
+
SHA: \${{ github.event.workflow_run.head_sha }}
|
|
67
|
+
run: |
|
|
68
|
+
repo="ghcr.io/\${GITHUB_REPOSITORY,,}"
|
|
69
|
+
{
|
|
70
|
+
echo "short=\${repo}:sha-\${SHA::7}"
|
|
71
|
+
echo "full=\${repo}:\${SHA}"
|
|
72
|
+
} >> "$GITHUB_OUTPUT"
|
|
73
|
+
# The plain docker CLI every GitHub-hosted runner ships — no third-party action holds the
|
|
74
|
+
# token. BUILD_ID is the commit, the stamp ${IMAGE_DOCKERFILE} documents.
|
|
75
|
+
- name: log in to ghcr.io
|
|
76
|
+
env:
|
|
77
|
+
TOKEN: \${{ github.token }}
|
|
78
|
+
run: printf '%s' "$TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
|
79
|
+
- name: build
|
|
80
|
+
env:
|
|
81
|
+
SHA: \${{ github.event.workflow_run.head_sha }}
|
|
82
|
+
SHORT: \${{ steps.image.outputs.short }}
|
|
83
|
+
FULL: \${{ steps.image.outputs.full }}
|
|
84
|
+
run: docker build -f ${IMAGE_DOCKERFILE} --build-arg "BUILD_ID=$SHA" -t "$SHORT" -t "$FULL" .
|
|
85
|
+
- name: push
|
|
86
|
+
env:
|
|
87
|
+
SHORT: \${{ steps.image.outputs.short }}
|
|
88
|
+
FULL: \${{ steps.image.outputs.full }}
|
|
89
|
+
run: |
|
|
90
|
+
docker push "$SHORT"
|
|
91
|
+
docker push "$FULL"
|
|
92
|
+
`;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The `runs-on:` the workflows `x new` writes: the gate on a free GitHub-hosted runner unless the
|
|
2
|
+
// repository says otherwise, the image build always on that default — it needs bash and Docker,
|
|
3
|
+
// which a configured label need not have. Spelled once so the two cannot drift.
|
|
4
|
+
|
|
5
|
+
/** The repository variable that overrides the runner — Settings → Secrets and variables → Actions. */
|
|
6
|
+
export const RUNNER_VARIABLE = 'CI_RUNNER';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The default label. GitHub-hosted and free for a public repository; a paid or self-hosted runner
|
|
10
|
+
* is a choice an owner makes by setting the variable, never one a scaffold makes for them.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_RUNNER = 'ubuntu-latest';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `vars.*` is an expression context `runs-on` accepts, and an unset variable reads as `''`, so the
|
|
16
|
+
* `||` falls through to the default on every repository that never set one.
|
|
17
|
+
*/
|
|
18
|
+
export const RUNS_ON = `\${{ vars.${RUNNER_VARIABLE} || '${DEFAULT_RUNNER}' }}`;
|
|
@@ -28,9 +28,11 @@ const CODE = '${CODE}';
|
|
|
28
28
|
/**
|
|
29
29
|
* A THROW, never a construction. \`new Error(…)\` handed to something as INPUT is legitimate — a
|
|
30
30
|
* test fixture, an \`AbortSignal\` reason, a rejection this module is passing along — and only the
|
|
31
|
-
* throw is this module stating its own verdict.
|
|
31
|
+
* throw is this module stating its own verdict. \`new\` is optional — \`throw Error(…)\` builds the
|
|
32
|
+
* same bare error — and every builtin class is listed: each carries no code, cause or fix.
|
|
32
33
|
*/
|
|
33
|
-
const BARE_THROW =
|
|
34
|
+
const BARE_THROW =
|
|
35
|
+
/\\bthrow\\s+(?:new\\s+)?(Error|TypeError|RangeError|SyntaxError|ReferenceError|EvalError|URIError|AggregateError)\\s*\\(/g;
|
|
34
36
|
|
|
35
37
|
export interface SourceFile {
|
|
36
38
|
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
@@ -101,6 +103,18 @@ unitTest('TypeError and RangeError are the same rule', () => {
|
|
|
101
103
|
expect(bareThrows(file("throw new RangeError('x');"))).toHaveLength(1);
|
|
102
104
|
});
|
|
103
105
|
|
|
106
|
+
unitTest('a throw with no \`new\` builds the same bare Error', () => {
|
|
107
|
+
expect(bareThrows(file("throw Error('no post');"))).toHaveLength(1);
|
|
108
|
+
expect(bareThrows(file("throw TypeError('x');"))).toHaveLength(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
unitTest('every builtin Error class is the same rule', () => {
|
|
112
|
+
for (const name of ['SyntaxError', 'ReferenceError', 'EvalError', 'URIError', 'AggregateError']) {
|
|
113
|
+
expect(bareThrows(file(\`throw new \${name}('x');\`))).toHaveLength(1);
|
|
114
|
+
}
|
|
115
|
+
expect(bareThrows(file("throw new MyTypeError('x');"))).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
104
118
|
unitTest('an UltimateError subclass is what the rule asks for', () => {
|
|
105
119
|
expect(bareThrows(file('throw new PostError(missingPost(id));'))).toEqual([]);
|
|
106
120
|
});
|
|
@@ -25,8 +25,29 @@ import type { Finding, Guard } from '@ultimat3/cli';
|
|
|
25
25
|
/** The app owns the codes its own conventions raise — this one is named for the guard. */
|
|
26
26
|
const CODE = '${CODE}';
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Every call whose output depends on a zone. The \`(\` is the start of the argument list.
|
|
30
|
+
* \`.toDateString()\` / \`.toTimeString()\` take no options at all: always the host's zone.
|
|
31
|
+
*/
|
|
32
|
+
const FORMATTER =
|
|
33
|
+
/(?:\\.toLocale(?:Date|Time)?String|\\.to(?:Date|Time)String|Intl\\.DateTimeFormat)\\s*\\(/g;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A \`timeZone\` KEY — written \`timeZone: z\`, the shorthand \`{ timeZone }\`, or quoted
|
|
37
|
+
* \`{ 'timeZone': z }\`, which is the same property. A substring test read \`timeZoneName\`, which
|
|
38
|
+
* only labels the zone the host already chose, as a zone. A quoted key takes only \`:\` — a
|
|
39
|
+
* quoted \`'timeZone',\` is a string in a list, not an option.
|
|
40
|
+
*/
|
|
41
|
+
const ZONED = /\\btimeZone\\s*[:,}]|(['"])timeZone\\1\\s*:/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The zoned call that replaces a formatter with no zone option. A \`Map\`, not an object: an
|
|
45
|
+
* object literal's prototype answers \`toLocaleString\`, and the bare call would read as one.
|
|
46
|
+
*/
|
|
47
|
+
const ZONED_FORM: ReadonlyMap<string, string> = new Map([
|
|
48
|
+
['toDateString', 'toLocaleDateString'],
|
|
49
|
+
['toTimeString', 'toLocaleTimeString'],
|
|
50
|
+
]);
|
|
30
51
|
|
|
31
52
|
export interface SourceFile {
|
|
32
53
|
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
@@ -72,19 +93,24 @@ export function unzonedDates(files: readonly SourceFile[]): readonly Finding[] {
|
|
|
72
93
|
const text = blank(file.source);
|
|
73
94
|
for (const match of text.matchAll(FORMATTER)) {
|
|
74
95
|
const open = match.index + match[0].length - 1;
|
|
75
|
-
if (argumentsOf(text, open)
|
|
96
|
+
if (ZONED.test(argumentsOf(text, open))) continue;
|
|
76
97
|
const line = lineOf(text, match.index);
|
|
77
98
|
// The bare \`.toLocaleString(\` is ALSO \`Number.prototype.toLocaleString\`, and a regex cannot
|
|
78
99
|
// tell a count from a date. The match stands — a date formatted this way is the defect this
|
|
79
100
|
// guard exists for — but the fix names the number exit too, because \`{ timeZone }\` is not
|
|
80
101
|
// one: a number ignores it, and an author following the fix verbatim would ship a lie.
|
|
81
102
|
const bare = match[0].trim() === '.toLocaleString(';
|
|
103
|
+
const called = match[0].replaceAll(/[.(\\s]/g, '');
|
|
104
|
+
const zoned = ZONED_FORM.get(called);
|
|
82
105
|
findings.push({
|
|
83
106
|
code: CODE,
|
|
84
107
|
cause: \`\${file.path}:\${line} calls \${match[0].trim()}) with no timeZone — it formats in whatever zone the process happens to run in, so one row reads as two different days across two containers\`,
|
|
85
|
-
fix:
|
|
86
|
-
|
|
87
|
-
|
|
108
|
+
fix:
|
|
109
|
+
zoned !== undefined
|
|
110
|
+
? \`\${called}() has no zone option — in \${file.path} call \${zoned}(locale, { timeZone: 'UTC' }) instead — then: x verify\`
|
|
111
|
+
: bare
|
|
112
|
+
? \`in \${file.path}: a Date → pass an explicit IANA zone, at.toLocaleString(locale, { timeZone: 'UTC' }); a NUMBER → new Intl.NumberFormat(locale).format(n) instead — then: x verify\`
|
|
113
|
+
: \`pass an explicit IANA zone in \${file.path} — toLocaleDateString(locale, { timeZone: 'UTC' }) — then: x verify\`,
|
|
88
114
|
at: file.path,
|
|
89
115
|
});
|
|
90
116
|
}
|
|
@@ -143,6 +169,30 @@ unitTest('the bare toLocaleString names the number exit, since a count matches i
|
|
|
143
169
|
expect(dated[0]?.fix).not.toContain('Intl.NumberFormat');
|
|
144
170
|
});
|
|
145
171
|
|
|
172
|
+
unitTest('timeZoneName labels the zone, it does not choose one', () => {
|
|
173
|
+
const named = "at.toLocaleString('en-US', { timeZoneName: 'short' });";
|
|
174
|
+
expect(unzonedDates(file(named))).toHaveLength(1);
|
|
175
|
+
const shorthand =
|
|
176
|
+
"new Intl.DateTimeFormat('en-US', { timeZoneName: 'short', timeZone }).format(at);";
|
|
177
|
+
expect(unzonedDates(file(shorthand))).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
unitTest('a quoted key is the same key, and a quoted timeZoneName is still only a label', () => {
|
|
181
|
+
expect(unzonedDates(file("at.toLocaleString('en-US', { 'timeZone': 'UTC' });"))).toEqual([]);
|
|
182
|
+
expect(unzonedDates(file('at.toLocaleString("en-US", { "timeZone": zone });'))).toEqual([]);
|
|
183
|
+
const label = "at.toLocaleString('en-US', { 'timeZoneName': 'short' });";
|
|
184
|
+
expect(unzonedDates(file(label))).toHaveLength(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
unitTest('toDateString and toTimeString name the zoned call', () => {
|
|
188
|
+
const date = unzonedDates(file('const shown = at.toDateString();'));
|
|
189
|
+
expect(date).toHaveLength(1);
|
|
190
|
+
expect(date[0]?.fix).toContain('toLocaleDateString(locale');
|
|
191
|
+
const time = unzonedDates(file('const shown = at.toTimeString();'));
|
|
192
|
+
expect(time).toHaveLength(1);
|
|
193
|
+
expect(time[0]?.fix).toContain('toLocaleTimeString(locale');
|
|
194
|
+
});
|
|
195
|
+
|
|
146
196
|
unitTest('a commented-out call is a note, not a call', () => {
|
|
147
197
|
expect(unzonedDates(file("// at.toLocaleDateString('en-US');"))).toEqual([]);
|
|
148
198
|
});
|
package/src/templates/index.ts
CHANGED
|
@@ -40,7 +40,7 @@ export { claudeAgentFiles } from './scaffold-claude-agents';
|
|
|
40
40
|
export { claudeCommandFiles } from './scaffold-claude-commands';
|
|
41
41
|
export { containerFiles } from './scaffold-container';
|
|
42
42
|
export { dashboardFiles } from './scaffold-dashboard';
|
|
43
|
-
export { docsFiles
|
|
43
|
+
export { docsFiles } from './scaffold-docs';
|
|
44
44
|
export { entryFiles } from './scaffold-entries';
|
|
45
45
|
export { errorPageFiles, PWA_COLORS } from './scaffold-errors';
|
|
46
46
|
// The guards `x new` ships — `SHIPPED_GUARD_NAMES` is the list — distinct from `guardFiles` above,
|
|
@@ -28,7 +28,7 @@ const HEADER = (
|
|
|
28
28
|
const storedRowTest = (name: NameSet): string => `
|
|
29
29
|
unitTest('a stored row is loaded, then processed, once each', async ({ runJobs }) => {
|
|
30
30
|
const draft = { orgId, title: 'kept', price: { minor: 0, currency: 'USD' } };
|
|
31
|
-
const row = await runWithContext(
|
|
31
|
+
const row = await runWithContext(ctxOf({ actor: member }), () => repo.insert(draft));
|
|
32
32
|
const trace = await runJobs(${name.camel}, { id: row.id, orgId });
|
|
33
33
|
expect(trace.executions.map((run) => run.outcome)).toEqual(['completed']);
|
|
34
34
|
expect(trace.executions[0]?.result).toEqual({ skipped: false });
|
|
@@ -41,7 +41,7 @@ const scopedBodyTest = (name: NameSet, shape: JobBodyShape): string => `${HEADER
|
|
|
41
41
|
${sortedImports([
|
|
42
42
|
...(shape.storesRow
|
|
43
43
|
? [
|
|
44
|
-
"import {
|
|
44
|
+
"import { ctxOf, runWithContext } from '@ultimat3/core';",
|
|
45
45
|
"import { testActor } from '@ultimat3/policy';",
|
|
46
46
|
"import { afterEach, expect, unitTest } from '@ultimat3/testing';",
|
|
47
47
|
`import { driver } from '${shape.dbModule}';`,
|
package/src/templates/job.ts
CHANGED
|
@@ -129,7 +129,7 @@ const jobTest = (
|
|
|
129
129
|
name: NameSet,
|
|
130
130
|
): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
|
|
131
131
|
// are the framework's, so what this pins is that THIS job's steps run and are idempotent.
|
|
132
|
-
import {
|
|
132
|
+
import { memoryJobDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
133
133
|
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
134
134
|
import { ${name.camel} } from './${name.kebab}';
|
|
135
135
|
|
|
@@ -143,7 +143,7 @@ const expectedKey = \`${name.kebab}:\${id}\`;
|
|
|
143
143
|
// The driver is process-global, so it is installed and released around this file rather than
|
|
144
144
|
// left behind for whichever test happens to run next.
|
|
145
145
|
beforeAll(() => {
|
|
146
|
-
setJobDriver(
|
|
146
|
+
setJobDriver(memoryJobDriver());
|
|
147
147
|
});
|
|
148
148
|
afterAll(resetJobDriver);
|
|
149
149
|
|
|
@@ -186,7 +186,7 @@ const neutralJobTest = (
|
|
|
186
186
|
name: NameSet,
|
|
187
187
|
): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
|
|
188
188
|
// are the framework's, so what this pins is that THIS job's steps run and are idempotent.
|
|
189
|
-
import {
|
|
189
|
+
import { memoryJobDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
190
190
|
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
191
191
|
import { ${name.camel} } from './${name.kebab}';
|
|
192
192
|
|
|
@@ -199,7 +199,7 @@ const expectedKey = \`${name.kebab}:\${id}\`;
|
|
|
199
199
|
// The driver is process-global, so it is installed and released around this file rather than
|
|
200
200
|
// left behind for whichever test happens to run next.
|
|
201
201
|
beforeAll(() => {
|
|
202
|
-
setJobDriver(
|
|
202
|
+
setJobDriver(memoryJobDriver());
|
|
203
203
|
});
|
|
204
204
|
afterAll(resetJobDriver);
|
|
205
205
|
|
|
@@ -243,13 +243,13 @@ const taskTest = (
|
|
|
243
243
|
jobName: NameSet,
|
|
244
244
|
): string => `// ${name.camel}: the schedule it declares, the timezone it declares it in, and the job it
|
|
245
245
|
// enqueues. A cron with no explicit IANA zone fires at a different hour twice a year.
|
|
246
|
-
import {
|
|
246
|
+
import { memoryJobDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
247
247
|
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
248
248
|
import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
|
|
249
249
|
import { ${name.camel} } from './${name.kebab}';
|
|
250
250
|
|
|
251
251
|
beforeAll(() => {
|
|
252
|
-
setJobDriver(
|
|
252
|
+
setJobDriver(memoryJobDriver());
|
|
253
253
|
});
|
|
254
254
|
afterAll(resetJobDriver);
|
|
255
255
|
|
package/src/templates/query.ts
CHANGED
|
@@ -63,7 +63,7 @@ const queryTest = (name: NameSet, feature: NameSet, shape: ReadShape): string =>
|
|
|
63
63
|
const subscribes = live && shape.storesRow;
|
|
64
64
|
const imports = subscribes
|
|
65
65
|
? [
|
|
66
|
-
"import {
|
|
66
|
+
"import { ctxOf, frozenClock, runWithContext } from '@ultimat3/core';",
|
|
67
67
|
`import { afterEach, expect, ${wrapper} } from '@ultimat3/testing';`,
|
|
68
68
|
`import { driver } from '${shape.dbModule}';`,
|
|
69
69
|
]
|
|
@@ -128,10 +128,11 @@ ${wrapper}('${name.camel} denies an orgless actor before a read', async () => {
|
|
|
128
128
|
expect(denied).toBeUltimateError('X_FORBIDDEN');
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
${wrapper}('${name.camel}
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
expect(target.
|
|
131
|
+
${wrapper}('${name.camel} is offered to agents as a read', () => {
|
|
132
|
+
// \`@ultimat3/mcp\`'s \`toolFrom\` serves this block as a read (\`mutates: false\`), through
|
|
133
|
+
// \`.as()\` under this same policy — an agent cannot reach a different authz path.
|
|
134
|
+
expect(target.kind).toBe('query');
|
|
135
|
+
expect(target.mcp?.expose).toBe(true);
|
|
135
136
|
});
|
|
136
137
|
${subscribes ? subscribeTest(name, feature) : ''}`;
|
|
137
138
|
};
|
|
@@ -160,7 +161,7 @@ const clock = frozenClock('2026-01-01T00:00:00.000Z');
|
|
|
160
161
|
|
|
161
162
|
const store = (org: string, title: string) => {
|
|
162
163
|
const draft = { orgId: org, title, price: { minor: 0, currency: 'USD' } };
|
|
163
|
-
const writer =
|
|
164
|
+
const writer = ctxOf({ actor: testActor('writer', { orgId: org }).actor, clock });
|
|
164
165
|
return runWithContext(writer, () => repo.insert(draft));
|
|
165
166
|
};
|
|
166
167
|
|
|
@@ -224,7 +225,7 @@ const clock = frozenClock('2026-01-01T00:00:00.000Z');
|
|
|
224
225
|
|
|
225
226
|
const store = (owner: string, title: string) => {
|
|
226
227
|
const draft = { orgId: owner, title, price: { minor: 0, currency: 'USD' } };
|
|
227
|
-
const writer =
|
|
228
|
+
const writer = ctxOf({ actor: testActor('writer', { orgId: owner }).actor, clock });
|
|
228
229
|
return runWithContext(writer, () => repo.insert(draft));
|
|
229
230
|
};
|
|
230
231
|
|
|
@@ -287,7 +288,7 @@ ${shape.storesRow ? storedRowsTest() : ''}`;
|
|
|
287
288
|
/** The imports the read cases need, over whatever the file they land in already imports. */
|
|
288
289
|
const readImports = (shape: ReadShape, testing: readonly string[]): readonly string[] => [
|
|
289
290
|
...(shape.storesRow
|
|
290
|
-
? ["import {
|
|
291
|
+
? ["import { ctxOf, frozenClock, runWithContext } from '@ultimat3/core';"]
|
|
291
292
|
: []),
|
|
292
293
|
`import { ${[...(shape.storesRow ? ['afterEach'] : []), ...testing].join(', ')} } from '@ultimat3/testing';`,
|
|
293
294
|
...(shape.storesRow ? [`import { driver } from '${shape.dbModule}';`] : []),
|
|
@@ -12,15 +12,17 @@ import { LINE_WIDTH } from './wrap';
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* The page's JS budget — MEASURED, never a ceiling picked to pass. `x build --target static` on a
|
|
15
|
-
* fresh `x new` app with `x g resource widget
|
|
16
|
-
* form island
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* fresh `x new` app with `x g resource widget` under CI's `file:` links, Bun 1.4.2 (CI's pin),
|
|
16
|
+
* 2026-10-05: 59,679 B — the form island 58,824 + the `visible` hydration runtime 855 (57,667 + 855
|
|
17
|
+
* on 1.4.0). 64kb is 65,536: the headroom is Bun's own movement on one source between releases —
|
|
18
|
+
* 1.4.0 → 1.4.2 moved this island +1,157 B, and a patch release that keeps core's declared modules
|
|
19
|
+
* measured +3.0 kB on the scaffold's theme island. Two builds of one source are
|
|
20
|
+
* byte-identical since 1.4.1 (issue #354), so no headroom is held for run-to-run movement. An app
|
|
19
21
|
* that adds per-document scripts (`navigation: { client: [...] }` charges its router, ~18 kB, to
|
|
20
22
|
* every `app/` page) is over it on its first build, and `nextSteps` sends the author there.
|
|
21
23
|
*/
|
|
22
24
|
export const RESOURCE_PAGE_BUDGET = '64kb';
|
|
23
|
-
export const RESOURCE_PAGE_MEASURED = '
|
|
25
|
+
export const RESOURCE_PAGE_MEASURED = '59,679 B';
|
|
24
26
|
|
|
25
27
|
/** How the page reaches a string — the same rule `route.ts` states, for the same issue (#249). */
|
|
26
28
|
const catalogImport = (module: string | undefined): string =>
|
|
@@ -186,7 +188,7 @@ const pageTest = (options: ResourcePageOptions): string => {
|
|
|
186
188
|
${sortedImports([
|
|
187
189
|
catalogImport(options.catalogModule),
|
|
188
190
|
`import { driver } from '${options.dbModule}';`,
|
|
189
|
-
"import {
|
|
191
|
+
"import { ctxOf, frozenClock, runWithContext } from '@ultimat3/core';",
|
|
190
192
|
"import { testActor } from '@ultimat3/policy';",
|
|
191
193
|
"import { afterEach, expect, renderRoute, unitTest } from '@ultimat3/testing';",
|
|
192
194
|
])}
|
|
@@ -209,7 +211,7 @@ const clock = frozenClock('2026-01-01T00:00:00.000Z');
|
|
|
209
211
|
|
|
210
212
|
const store = (title: string) => {
|
|
211
213
|
const draft = { orgId, title, price: { minor: 1200, currency: 'USD' } };
|
|
212
|
-
return runWithContext(
|
|
214
|
+
return runWithContext(ctxOf({ actor: member, clock }), () => repo.insert(draft));
|
|
213
215
|
};
|
|
214
216
|
|
|
215
217
|
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
@@ -43,7 +43,7 @@ const serviceTest = (
|
|
|
43
43
|
): string => `// The ${feature.kebab} service against the in-memory driver, and its failure, pinned: a code an
|
|
44
44
|
// agent can match on, a cause naming the row, and a fix that is an instruction.
|
|
45
45
|
${sortedImports([
|
|
46
|
-
"import {
|
|
46
|
+
"import { ctxOf, runWithContext } from '@ultimat3/core';",
|
|
47
47
|
"import { testActor } from '@ultimat3/policy';",
|
|
48
48
|
"import { afterEach, expect, unitTest } from '@ultimat3/testing';",
|
|
49
49
|
`import { driver } from '${dbModule}';`,
|
|
@@ -57,7 +57,7 @@ const draft = { orgId, title: 'First', price: { minor: 1200, currency: 'USD' } }
|
|
|
57
57
|
|
|
58
58
|
/** What a request is to the repo underneath: an actor, whose org every statement runs under. */
|
|
59
59
|
const inOrg = <T>(run: () => Promise<T>): Promise<T> =>
|
|
60
|
-
runWithContext(
|
|
60
|
+
runWithContext(ctxOf({ actor: testActor('member', { orgId }).actor }), run);
|
|
61
61
|
|
|
62
62
|
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
63
63
|
afterEach(() => {
|
package/src/templates/route.ts
CHANGED
|
@@ -51,13 +51,13 @@ const DYNAMIC_SEGMENT = /^\[(?<spread>\.\.\.)?(?<name>[^\]]+)\]$/;
|
|
|
51
51
|
/**
|
|
52
52
|
* One URL segment. `kebab()` strips `[` and `]` along with every other non-alphanumeric, so
|
|
53
53
|
* `x g route "posts/[slug]"` scaffolded `apps/web/app/posts/slug/page.tsx` — a different, STATIC
|
|
54
|
-
* route — and reported `ok:true` with no warning.
|
|
54
|
+
* route — and reported `ok:true` with no warning. A dynamic segment is now kept exactly as the
|
|
55
|
+
* author wrote it: its name is the key the router puts in `params`, so kebabing it renamed
|
|
56
|
+
* `[orderId]` to `[order-id]` behind the author's back — the page read `params.orderId` and got
|
|
57
|
+
* `undefined`.
|
|
55
58
|
*/
|
|
56
|
-
export const routeSegment = (part: string): string =>
|
|
57
|
-
|
|
58
|
-
if (match === null) return kebab(part);
|
|
59
|
-
return `[${match.groups?.['spread'] ?? ''}${kebab(match.groups?.['name'] ?? '')}]`;
|
|
60
|
-
};
|
|
59
|
+
export const routeSegment = (part: string): string =>
|
|
60
|
+
DYNAMIC_SEGMENT.test(part) ? part : kebab(part);
|
|
61
61
|
|
|
62
62
|
const segmentsOf = (path: string): readonly string[] =>
|
|
63
63
|
path
|
|
@@ -154,11 +154,26 @@ const sampleUrl = (path: string): string =>
|
|
|
154
154
|
})
|
|
155
155
|
.join('/');
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A single-quoted TS string literal — the repo's (and the scaffold's Biome config's) quote style,
|
|
161
|
+
* so the generated file passes `lint` as written. `JSON.stringify` does the escaping.
|
|
162
|
+
*/
|
|
163
|
+
const singleQuoted = (value: string): string =>
|
|
164
|
+
`'${JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'")}'`;
|
|
165
|
+
|
|
166
|
+
/** An object key as TS accepts it: bare when it is an identifier, quoted when it is not. */
|
|
167
|
+
const objectKey = (name: string): string => (IDENTIFIER.test(name) ? name : singleQuoted(name));
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The params object that URL implies. `{}` was a lie for every dynamic route; a bare
|
|
171
|
+
* `order-id:` key was a syntax error in the test of every kebab-named param.
|
|
172
|
+
*/
|
|
158
173
|
const paramsLiteral = (path: string): string => {
|
|
159
174
|
const params = routeParams(path);
|
|
160
175
|
if (params.length === 0) return '{}';
|
|
161
|
-
return `{ ${params.map((name) => `${name}:
|
|
176
|
+
return `{ ${params.map((name) => `${objectKey(name)}: ${singleQuoted(sampleValue(name))}`).join(', ')} }`;
|
|
162
177
|
};
|
|
163
178
|
|
|
164
179
|
const routeTest = (
|
|
@@ -41,9 +41,8 @@ contractTest('health is an action exposed over MCP', () => {
|
|
|
41
41
|
expect(target.mcp?.expose).toBe(true);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
contractTest('health projects one
|
|
45
|
-
//
|
|
46
|
-
expect(target.tool().policy).toBe(target.policy);
|
|
44
|
+
contractTest('health projects one OpenAPI operation under its export name', () => {
|
|
45
|
+
// The MCP tool is \`@ultimat3/mcp\`'s \`toolFrom\`, served from the \`mcp\` block above.
|
|
47
46
|
expect(target.openapi().operationId).toBe('health');
|
|
48
47
|
});
|
|
49
48
|
`;
|
|
@@ -200,7 +200,8 @@ import './global';
|
|
|
200
200
|
|
|
201
201
|
unitTest('importing shared/global registers the one global stylesheet', () => {
|
|
202
202
|
const sheets = registeredStylesheets().filter((sheet) => sheet.global);
|
|
203
|
-
|
|
203
|
+
// Split on either separator: \`file\` is a host path, \`D:\\…\\global.scss\` on Windows.
|
|
204
|
+
expect(sheets.map((sheet) => sheet.file.split(/[\\\\/]/).slice(-3).join('/'))).toEqual([
|
|
204
205
|
'web/shared/global.scss',
|
|
205
206
|
]);
|
|
206
207
|
// The custom properties themselves, declared once on the root — what \`tokens.role()\` refers to.
|