@ultimat3/cli 22.15.0 → 24.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 +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
package/src/cmd-secrets.ts
CHANGED
|
@@ -25,25 +25,68 @@ import {
|
|
|
25
25
|
SecretsPlaintextInvalidError,
|
|
26
26
|
secretsFileExists,
|
|
27
27
|
serializeSecretValues,
|
|
28
|
+
UltimateError,
|
|
28
29
|
writeMasterKeyFile,
|
|
29
30
|
writeSecretsFile,
|
|
30
31
|
} from '@ultimat3/core';
|
|
31
32
|
import { ENV_SCHEMA_EXPORT, loadEnvSchema } from './app-env';
|
|
32
33
|
import { requireAppRoot } from './app-root';
|
|
34
|
+
import { appSecrets, dropRetired, retire, retiredKeyIds } from './cmd-secrets-ring';
|
|
33
35
|
import { secretsSpec } from './cmd-secrets-spec';
|
|
34
36
|
import type { CliCommand, CommandContext } from './command';
|
|
35
|
-
import {
|
|
36
|
-
MissingPositionalError,
|
|
37
|
-
SecretsEditFailedError,
|
|
38
|
-
SecretsEditorMissingError,
|
|
39
|
-
SecretsExistsError,
|
|
40
|
-
} from './errors';
|
|
37
|
+
import { MissingPositionalError } from './errors';
|
|
41
38
|
import { msg } from './messages';
|
|
42
39
|
import type { CommandResult, JsonValue } from './output';
|
|
40
|
+
import { flagString } from './parse';
|
|
43
41
|
import { recoverRotation, rotateMasterKey } from './secrets-rotation';
|
|
44
42
|
import { shredOnSignal } from './signal-shred';
|
|
45
43
|
import { renderTable } from './table';
|
|
46
44
|
|
|
45
|
+
/**
|
|
46
|
+
* `x secrets edit` decrypts into a buffer and hands it to `$EDITOR`. There is no fallback editor:
|
|
47
|
+
* guessing one and opening a decrypted file in it is the last place a surprise belongs.
|
|
48
|
+
*/
|
|
49
|
+
export class SecretsEditorMissingError extends UltimateError {
|
|
50
|
+
constructor(input: { vars: readonly string[] }) {
|
|
51
|
+
super({
|
|
52
|
+
code: 'X_SECRETS_EDITOR_MISSING',
|
|
53
|
+
cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
|
|
54
|
+
fix: 'EDITOR=nano x secrets edit',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The editor exited non-zero — a crash, or a deliberate abort. The buffer is discarded either way
|
|
61
|
+
* and the committed file is left exactly as it was: resealing a buffer whose editor failed would
|
|
62
|
+
* commit whatever half-written state the crash left behind.
|
|
63
|
+
*/
|
|
64
|
+
export class SecretsEditFailedError extends UltimateError {
|
|
65
|
+
constructor(input: { editor: string; code: number }) {
|
|
66
|
+
super({
|
|
67
|
+
code: 'X_SECRETS_EDIT_FAILED',
|
|
68
|
+
cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
|
|
69
|
+
fix: 'x secrets edit',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `x secrets init` would overwrite a file that already exists. `X_GENERATE_CONFLICT` is this
|
|
76
|
+
* package's own code for exactly that, and a second name for "a generator would clobber something"
|
|
77
|
+
* is the duplication the code registry exists to prevent. Losing a master key is unrecoverable —
|
|
78
|
+
* the committed file it opens is then ciphertext nobody can read again.
|
|
79
|
+
*/
|
|
80
|
+
export class SecretsExistsError extends UltimateError {
|
|
81
|
+
constructor(input: { path: string; fix: string }) {
|
|
82
|
+
super({
|
|
83
|
+
code: 'X_GENERATE_CONFLICT',
|
|
84
|
+
cause: `${input.path} already exists, and x secrets init would replace it`,
|
|
85
|
+
fix: input.fix,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
47
90
|
export { SECRETS_SUBCOMMANDS } from './cmd-secrets-spec';
|
|
48
91
|
|
|
49
92
|
/** In order of precedence. `VISUAL` outranks `EDITOR` by POSIX convention on an interactive tty. */
|
|
@@ -244,36 +287,65 @@ async function set(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
|
|
|
244
287
|
};
|
|
245
288
|
}
|
|
246
289
|
|
|
290
|
+
/**
|
|
291
|
+
* The closing half of a rotation: one retired key leaves the ring, by id, once nothing sealed
|
|
292
|
+
* under it is left. No new key is generated and the key file is not touched — the committed file
|
|
293
|
+
* is resealed under the key already in force.
|
|
294
|
+
*/
|
|
295
|
+
async function drop(session: Session, keyId: string): Promise<CommandResult> {
|
|
296
|
+
const { root, key } = session;
|
|
297
|
+
const after = await dropRetired(await readSecretsFile(root, key), keyId);
|
|
298
|
+
await writeSecretsFile(root, after, key);
|
|
299
|
+
const retired = await retiredKeyIds(after);
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
command: 'secrets',
|
|
303
|
+
summary: msg('cli.secrets.dropped', { path: SECRETS_FILE, kid: keyId, count: retired.length }),
|
|
304
|
+
data: {
|
|
305
|
+
path: SECRETS_FILE,
|
|
306
|
+
keyId: await masterKeyIdOf(key),
|
|
307
|
+
dropped: keyId,
|
|
308
|
+
retiredKeyIds: [...retired],
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
247
313
|
/**
|
|
248
314
|
* A new master key over the same values: staged beside the old one, the file sealed with it, then
|
|
249
315
|
* made live (`rotateMasterKey`). Sealed first with the key file written last, a crash between the
|
|
250
316
|
* two left a committed file no key on disk could open.
|
|
317
|
+
*
|
|
318
|
+
* The key being replaced joins the ring INSIDE the sealed file (`cmd-secrets-ring.ts`), so a value
|
|
319
|
+
* `seal()` wrote under it still opens and a deploy is still handed exactly one key.
|
|
251
320
|
*/
|
|
252
321
|
async function rotate(ctx: CommandContext): Promise<CommandResult> {
|
|
253
|
-
const
|
|
254
|
-
const
|
|
322
|
+
const session = await open(ctx, 'rotate');
|
|
323
|
+
const dropping = flagString(ctx.args, 'drop');
|
|
324
|
+
if (dropping !== undefined) return drop(session, dropping);
|
|
325
|
+
const { root, key } = session;
|
|
326
|
+
const values = retire(await readSecretsFile(root, key), key.hex);
|
|
255
327
|
const previous = await masterKeyIdOf(key);
|
|
256
328
|
// Ignored BEFORE any key file is written, staged one included; the order inside is
|
|
257
329
|
// `secrets-rotation.ts`'s: stage the key, seal, then rename it live.
|
|
258
330
|
await ensureIgnored(root);
|
|
259
331
|
const next = await rotateMasterKey(root, values);
|
|
260
332
|
const keyId = await masterKeyIdOf(next);
|
|
333
|
+
const count = Object.keys(appSecrets(values)).length;
|
|
261
334
|
return {
|
|
262
335
|
ok: true,
|
|
263
336
|
command: 'secrets',
|
|
264
|
-
summary: msg('cli.secrets.rotated', {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}),
|
|
270
|
-
lines: [msg('cli.secrets.redeploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE })],
|
|
337
|
+
summary: msg('cli.secrets.rotated', { path: SECRETS_FILE, from: previous, to: keyId, count }),
|
|
338
|
+
lines: [
|
|
339
|
+
msg('cli.secrets.redeploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE }),
|
|
340
|
+
msg('cli.secrets.retired', { kid: previous }),
|
|
341
|
+
],
|
|
271
342
|
data: {
|
|
272
343
|
path: SECRETS_FILE,
|
|
273
344
|
keyPath: SECRETS_KEY_FILE,
|
|
274
345
|
previousKeyId: previous,
|
|
275
346
|
keyId,
|
|
276
|
-
count
|
|
347
|
+
count,
|
|
348
|
+
retiredKeyIds: [...(await retiredKeyIds(values))],
|
|
277
349
|
},
|
|
278
350
|
};
|
|
279
351
|
}
|
|
@@ -285,9 +357,12 @@ async function rotate(ctx: CommandContext): Promise<CommandResult> {
|
|
|
285
357
|
*/
|
|
286
358
|
async function show(ctx: CommandContext): Promise<CommandResult> {
|
|
287
359
|
const { root, key } = await open(ctx, 'show');
|
|
288
|
-
const
|
|
360
|
+
const sealed = await readSecretsFile(root, key);
|
|
361
|
+
// The ring is the framework's own entry: reported by key id below, never as an app secret that
|
|
362
|
+
// `envSchema` forgot to declare.
|
|
363
|
+
const retired = await retiredKeyIds(sealed);
|
|
289
364
|
const schema = await loadEnvSchema(root);
|
|
290
|
-
const summaries = describeSecrets(
|
|
365
|
+
const summaries = describeSecrets(appSecrets(sealed));
|
|
291
366
|
const keyId = await masterKeyIdOf(key);
|
|
292
367
|
const declared = (name: string): boolean | undefined =>
|
|
293
368
|
schema === undefined ? undefined : schema[name] !== undefined;
|
|
@@ -309,6 +384,7 @@ async function show(ctx: CommandContext): Promise<CommandResult> {
|
|
|
309
384
|
: [
|
|
310
385
|
msg('cli.secrets.undeclared', { count: undeclared.length, names: undeclared.join(', ') }),
|
|
311
386
|
]),
|
|
387
|
+
...(retired.length === 0 ? [] : [msg('cli.secrets.ring', { kids: retired.join(', ') })]),
|
|
312
388
|
];
|
|
313
389
|
return {
|
|
314
390
|
ok: true,
|
|
@@ -322,6 +398,7 @@ async function show(ctx: CommandContext): Promise<CommandResult> {
|
|
|
322
398
|
path: SECRETS_FILE,
|
|
323
399
|
keySource: key.source,
|
|
324
400
|
keyId,
|
|
401
|
+
retiredKeyIds: [...retired],
|
|
325
402
|
count: summaries.length,
|
|
326
403
|
secrets: summaries.map((entry) => ({
|
|
327
404
|
name: entry.name,
|
package/src/cmd-shot-matrix.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
// why: no Bun native joins a path.
|
|
7
7
|
import { join, relative } from 'node:path';
|
|
8
|
+
import { escapeHtml } from '@ultimat3/core';
|
|
8
9
|
import { routeEntries } from '@ultimat3/render';
|
|
9
10
|
import { loadApp } from './app-load';
|
|
10
11
|
import type { ShotRun } from './cmd-shot';
|
|
@@ -94,9 +95,6 @@ export interface MatrixShot {
|
|
|
94
95
|
readonly image: string;
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
const escapeHtml = (text: string): string =>
|
|
98
|
-
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
99
|
-
|
|
100
98
|
/**
|
|
101
99
|
* The contact sheet. System colours only (`Canvas`, `CanvasText`, `Mark`): a generated review page
|
|
102
100
|
* has no design tokens to read, and a literal colour would be the one this repo refuses.
|
package/src/cmd-shot.ts
CHANGED
|
@@ -339,6 +339,14 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
|
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Where the artifacts land. A relative `--out` is a path the caller typed from where they stand,
|
|
344
|
+
* so it resolves against the cwd — `x build --out`'s rule. Against the app root it landed
|
|
345
|
+
* somewhere else whenever `x shot` ran from `apps/web`.
|
|
346
|
+
*/
|
|
347
|
+
export const shotOutDir = (cwd: string, out: string | undefined, fallback: string): string =>
|
|
348
|
+
out === undefined ? fallback : resolve(cwd, out);
|
|
349
|
+
|
|
342
350
|
export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
|
|
343
351
|
ok: artifacts.verdict.ok,
|
|
344
352
|
command: 'shot',
|
|
@@ -426,7 +434,8 @@ export const shotCommand: CliCommand = {
|
|
|
426
434
|
const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
|
|
427
435
|
const shared = {
|
|
428
436
|
root,
|
|
429
|
-
|
|
437
|
+
// Absolute here, so the island sweep's own `resolve(root, out)` keeps the caller's path.
|
|
438
|
+
...(out === undefined ? {} : { out: shotOutDir(ctx.cwd, out, root) }),
|
|
430
439
|
settleMs,
|
|
431
440
|
timeoutMs,
|
|
432
441
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
@@ -465,7 +474,7 @@ export const shotCommand: CliCommand = {
|
|
|
465
474
|
defaultLocale: app.defaultLocale,
|
|
466
475
|
...(theme === undefined ? {} : { themes: [theme] }),
|
|
467
476
|
});
|
|
468
|
-
const outDir = out
|
|
477
|
+
const outDir = shotOutDir(ctx.cwd, out, join(root, MATRIX_DIR));
|
|
469
478
|
return runShotMatrix({ cells, outDir, boot, shoot: runShot, base });
|
|
470
479
|
}
|
|
471
480
|
const shown = locale ?? app.defaultLocale;
|
|
@@ -474,7 +483,7 @@ export const shotCommand: CliCommand = {
|
|
|
474
483
|
await runShot({
|
|
475
484
|
...base,
|
|
476
485
|
route: path,
|
|
477
|
-
outDir: out
|
|
486
|
+
outDir: shotOutDir(ctx.cwd, out, join(root, SHOT_DIR, shotSlug(path))),
|
|
478
487
|
boot,
|
|
479
488
|
// Pinned whether or not `--locale` was given: the default locale, never the box's own.
|
|
480
489
|
acceptLanguage: shown,
|
package/src/cmd-tasks.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// `x tasks [list|show <name>]` — introspect scheduled cron tasks: cadence, timezone, the jobs
|
|
2
|
-
// each enqueues,
|
|
3
|
-
//
|
|
4
|
-
// `
|
|
2
|
+
// each enqueues, the occurrence the scheduler LAST dispatched (off the queue's own record) and
|
|
3
|
+
// real next-occurrence instants from `@ultimat3/time`'s cron math instead of an agent reading
|
|
4
|
+
// `0 3 * * *` and guessing. CLI wiring only; the pure computation lives in `tasks-facts.ts` — the
|
|
5
|
+
// same split `cmd-jobs.ts` makes against `jobs-report.ts`.
|
|
5
6
|
|
|
6
7
|
import { nearestName, systemClock } from '@ultimat3/core';
|
|
7
|
-
import type { TaskHandle } from '@ultimat3/jobs';
|
|
8
|
+
import type { JobDriver, TaskFire, TaskHandle } from '@ultimat3/jobs';
|
|
8
9
|
import type { CronPhrases } from '@ultimat3/time';
|
|
10
|
+
import { fromEpochMs, isoInZone } from '@ultimat3/time';
|
|
9
11
|
import { loadApp } from './app-load';
|
|
10
12
|
import { requireAppRoot } from './app-root';
|
|
11
13
|
import { tasksSpec } from './cmd-tasks-spec';
|
|
12
14
|
import type { CliCommand, CommandContext } from './command';
|
|
13
15
|
import { BadFlagError, DeclarationUnknownError } from './errors';
|
|
16
|
+
import { withJobDriver } from './jobs-driver';
|
|
14
17
|
import { msg } from './messages';
|
|
15
18
|
import type { CommandResult, Finding, JsonValue } from './output';
|
|
16
19
|
import { flagString } from './parse';
|
|
@@ -24,7 +27,41 @@ import {
|
|
|
24
27
|
taskShowFacts,
|
|
25
28
|
} from './tasks-facts';
|
|
26
29
|
|
|
27
|
-
const HEADER = ['name', 'cron', 'tz', 'catchUp', 'jobs', 'next'] as const;
|
|
30
|
+
const HEADER = ['name', 'cron', 'tz', 'catchUp', 'jobs', 'last', 'next'] as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* When a task last fired, flat beside `nextMs` / `next`. All three are `null` for a task the
|
|
34
|
+
* scheduler has never dispatched — a key that is sometimes absent is a question asked two ways.
|
|
35
|
+
*/
|
|
36
|
+
interface LastFire {
|
|
37
|
+
/** Epoch ms of the occurrence it was scheduled FOR — the instant `last` renders. */
|
|
38
|
+
readonly lastMs: number | null;
|
|
39
|
+
readonly last: string | null;
|
|
40
|
+
/** Epoch ms the scheduler dispatched it, on the queue's clock. Later than `lastMs` by its lag. */
|
|
41
|
+
readonly lastFiredAtMs: number | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const NEVER_FIRED: LastFire = { lastMs: null, last: null, lastFiredAtMs: null };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The last occurrence each task DISPATCHED, by task name — `JobIntrospection.taskFires()`, never
|
|
48
|
+
* the scheduler's watermark, which arming and skipping move too. A driver with no introspection
|
|
49
|
+
* answers none, and every task then reads as never fired.
|
|
50
|
+
*/
|
|
51
|
+
async function lastFires(driver: JobDriver): Promise<ReadonlyMap<string, TaskFire>> {
|
|
52
|
+
const fires = (await driver.introspect?.taskFires()) ?? [];
|
|
53
|
+
return new Map(fires.map((fire) => [fire.task, fire]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const lastFireOf = (fires: ReadonlyMap<string, TaskFire>, name: string, tz: string): LastFire => {
|
|
57
|
+
const fire = fires.get(name);
|
|
58
|
+
if (fire === undefined) return NEVER_FIRED;
|
|
59
|
+
return {
|
|
60
|
+
lastMs: fire.occurrenceMs,
|
|
61
|
+
last: isoInZone(fromEpochMs(fire.occurrenceMs), tz),
|
|
62
|
+
lastFiredAtMs: fire.firedAt,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
28
65
|
|
|
29
66
|
/**
|
|
30
67
|
* The vocabulary `describeCron` interpolates. The cron *math* stays in `tasks-facts.ts` — it is
|
|
@@ -57,17 +94,25 @@ const detailLines = (payload: Readonly<Record<string, JsonValue>>): readonly str
|
|
|
57
94
|
|
|
58
95
|
const jobsCell = (jobs: readonly string[]): string => (jobs.length === 0 ? '-' : jobs.join(','));
|
|
59
96
|
|
|
60
|
-
const row = (fact: TaskFact): readonly string[] => [
|
|
97
|
+
const row = (fact: TaskFact & LastFire): readonly string[] => [
|
|
61
98
|
fact.name,
|
|
62
99
|
fact.cron,
|
|
63
100
|
fact.tz,
|
|
64
101
|
fact.catchUp,
|
|
65
102
|
jobsCell(fact.jobs),
|
|
103
|
+
fact.last ?? '-',
|
|
66
104
|
fact.next,
|
|
67
105
|
];
|
|
68
106
|
|
|
69
|
-
function runList(
|
|
70
|
-
|
|
107
|
+
function runList(
|
|
108
|
+
nowMs: number,
|
|
109
|
+
fires: ReadonlyMap<string, TaskFire>,
|
|
110
|
+
findings: readonly Finding[],
|
|
111
|
+
): CommandResult {
|
|
112
|
+
const facts = listTaskFacts(nowMs).map((fact) => ({
|
|
113
|
+
...fact,
|
|
114
|
+
...lastFireOf(fires, fact.name, fact.tz),
|
|
115
|
+
}));
|
|
71
116
|
return {
|
|
72
117
|
ok: findings.length === 0,
|
|
73
118
|
command: 'tasks',
|
|
@@ -101,13 +146,23 @@ function requireHandle(ctx: CommandContext): TaskHandle {
|
|
|
101
146
|
);
|
|
102
147
|
}
|
|
103
148
|
|
|
104
|
-
function runShow(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
149
|
+
function runShow(
|
|
150
|
+
shown: { readonly handle: TaskHandle; readonly count: number },
|
|
151
|
+
nowMs: number,
|
|
152
|
+
fires: ReadonlyMap<string, TaskFire>,
|
|
153
|
+
findings: readonly Finding[],
|
|
154
|
+
): CommandResult {
|
|
155
|
+
const { descriptor, describe, upcoming } = taskShowFacts(
|
|
156
|
+
shown.handle,
|
|
157
|
+
nowMs,
|
|
158
|
+
shown.count,
|
|
159
|
+
cronPhrases(),
|
|
160
|
+
);
|
|
108
161
|
const first = upcoming[0];
|
|
162
|
+
const fired = lastFireOf(fires, descriptor.name, descriptor.tz);
|
|
109
163
|
const lines = [
|
|
110
164
|
...detailLines(asJson(descriptor)),
|
|
165
|
+
` last: ${fired.last ?? '-'}`,
|
|
111
166
|
` ${describe}`,
|
|
112
167
|
...upcoming.map((occurrence) => ` ${occurrence.at}`),
|
|
113
168
|
];
|
|
@@ -124,6 +179,7 @@ function runShow(ctx: CommandContext, nowMs: number, findings: readonly Finding[
|
|
|
124
179
|
findings,
|
|
125
180
|
data: {
|
|
126
181
|
...asJson(descriptor),
|
|
182
|
+
...fired,
|
|
127
183
|
describe,
|
|
128
184
|
upcoming: upcoming.map((occurrence) => asJson(occurrence)),
|
|
129
185
|
},
|
|
@@ -136,8 +192,21 @@ export const tasksCommand: CliCommand = {
|
|
|
136
192
|
const root = requireAppRoot('tasks', ctx.cwd).dir;
|
|
137
193
|
const { findings } = await loadApp(root);
|
|
138
194
|
const nowMs = systemClock.now().getTime();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
195
|
+
// Everything argv can get wrong is refused BEFORE the queue is opened: a typo in a task name
|
|
196
|
+
// must not cost a database boot to be told about.
|
|
197
|
+
const shown =
|
|
198
|
+
ctx.args.subcommand === 'show'
|
|
199
|
+
? { handle: requireHandle(ctx), count: parseCountFlag(flagString(ctx.args, 'count')) }
|
|
200
|
+
: undefined;
|
|
201
|
+
// Nothing to join a fire to: an app that declares no task never pays for a queue.
|
|
202
|
+
if (shown === undefined && knownTaskNames().length === 0) {
|
|
203
|
+
return runList(nowMs, new Map(), findings);
|
|
204
|
+
}
|
|
205
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
206
|
+
const fires = await lastFires(driver);
|
|
207
|
+
return shown === undefined
|
|
208
|
+
? runList(nowMs, fires, findings)
|
|
209
|
+
: runShow(shown, nowMs, fires, findings);
|
|
210
|
+
});
|
|
142
211
|
},
|
|
143
212
|
};
|
package/src/cmd-test-spec.ts
CHANGED
|
@@ -10,7 +10,7 @@ export const testSpec: CommandSpec = {
|
|
|
10
10
|
name: 'test',
|
|
11
11
|
summary:
|
|
12
12
|
'run one test type — or the whole suite — across N workers, one isolated database per worker',
|
|
13
|
-
usage: `x test [${TEST_TYPES.join('|')}] [--filter path[,path…]] [--allow-empty] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--
|
|
13
|
+
usage: `x test [${TEST_TYPES.join('|')}] [--filter path[,path…]] [--allow-empty] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--isolate] [--json] [-- <bun test flags>]`,
|
|
14
14
|
positionalChoices: TEST_TYPES,
|
|
15
15
|
// The one command that hands a tail to another tool — `bun test` — and the reason
|
|
16
16
|
// `CommandSpec.passthrough` exists: `x test unit -- --coverage --bail` parsed both flags and
|
|
@@ -28,12 +28,6 @@ export const testSpec: CommandSpec = {
|
|
|
28
28
|
summary:
|
|
29
29
|
'a fresh global per test file (bun test --isolate) — off by default since 22.7; x.verify.json "isolate": true makes it the repo default',
|
|
30
30
|
},
|
|
31
|
-
{
|
|
32
|
-
name: 'worker',
|
|
33
|
-
type: 'string',
|
|
34
|
-
summary:
|
|
35
|
-
'run only shard I of an N-way split of the selection, serially — one CI job\u2019s share',
|
|
36
|
-
},
|
|
37
31
|
{
|
|
38
32
|
name: 'filter',
|
|
39
33
|
type: 'string',
|
package/src/cmd-test.ts
CHANGED
|
@@ -40,22 +40,20 @@ import type { TestType } from './verify-tests';
|
|
|
40
40
|
import { TEST_TYPES } from './verify-tests';
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* `--workers
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* `--workers`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while `cmd-verify.ts`'s
|
|
44
|
+
* own comment claimed `x test --workers` refused the same values `x verify` does — so the two
|
|
45
|
+
* commands disagreed about the same flag. One reader now answers for both.
|
|
46
46
|
*/
|
|
47
|
-
const
|
|
47
|
+
const readWorkers = (args: ParsedArgs): number | undefined =>
|
|
48
48
|
readIntFlag(args, {
|
|
49
|
-
name,
|
|
49
|
+
name: 'workers',
|
|
50
50
|
command: 'test',
|
|
51
|
-
min,
|
|
51
|
+
min: 1,
|
|
52
52
|
// The ceiling the summary already claimed and the reader never enforced: `--workers 5000` was
|
|
53
53
|
// accepted and the run clamps only to the file count, which is one Bun worker per test FILE,
|
|
54
|
-
// each with the framework module graph and a cloned database.
|
|
55
|
-
// N-way split, so the same bound holds it (the exact upper index is `workers - 1`, refused a
|
|
56
|
-
// line below by the check that knows the real width).
|
|
54
|
+
// each with the framework module graph and a cloned database.
|
|
57
55
|
max: WORKER_CEILING,
|
|
58
|
-
example:
|
|
56
|
+
example: 'x test --workers 1',
|
|
59
57
|
});
|
|
60
58
|
|
|
61
59
|
/**
|
|
@@ -168,29 +166,17 @@ export const testCommand: CliCommand = {
|
|
|
168
166
|
});
|
|
169
167
|
}
|
|
170
168
|
const files = sample === undefined ? selected : sampleFiles(selected, sample);
|
|
171
|
-
const explicit =
|
|
169
|
+
const explicit = readWorkers(ctx.args);
|
|
172
170
|
const plan =
|
|
173
171
|
explicit === undefined ? workerPlan(availableCpus(), totalMemory(), ctx.env) : undefined;
|
|
174
172
|
const requested = explicit ?? (plan as WorkerPlan).workers;
|
|
175
173
|
const floor = await readVerifyFloor(ctx.cwd);
|
|
176
174
|
const isolate = flagBool(ctx.args, 'isolate') || floor?.isolate === true;
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// the
|
|
180
|
-
// — true for `x test live`, false for the bare `x test --workers 8` that selects every type,
|
|
181
|
-
// which ran the same `live` and `e2e` files eight at a time. It stays because a `--worker` run
|
|
182
|
-
// is one process per CI JOB: sharding a serial type across N of them is the cluster-wide race
|
|
183
|
-
// in a second disguise, and refusing the index is where that is caught.
|
|
175
|
+
// A serial type is one worker whatever `--workers` says. `test-passes.ts` makes the same call
|
|
176
|
+
// off the FILES, which is what decides which files really run one at a time; this keeps the
|
|
177
|
+
// width the run reports and reproduces with the one it ran at.
|
|
184
178
|
const ceiling = type !== undefined && SERIAL_TYPES.includes(type) ? 1 : files.length;
|
|
185
179
|
const workers = Math.max(1, Math.min(requested, ceiling));
|
|
186
|
-
const only = readIndex(ctx.args, 'worker', 0);
|
|
187
|
-
if (only !== undefined && only >= workers) {
|
|
188
|
-
throw new BadFlagError({
|
|
189
|
-
flag: 'worker',
|
|
190
|
-
command: 'test',
|
|
191
|
-
reason: `shard ${only} does not exist in a ${workers}-worker split (0..${workers - 1})`,
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
180
|
const result = await runShards({
|
|
195
181
|
root: ctx.cwd,
|
|
196
182
|
runner: ctx.runner,
|
|
@@ -201,11 +187,9 @@ export const testCommand: CliCommand = {
|
|
|
201
187
|
// A default-width run leases its workers from the machine pool, so a second `x test` or
|
|
202
188
|
// gate on the same box runs narrower instead of doubling its memory.
|
|
203
189
|
...(plan === undefined ? {} : withLease(machineLease(plan.workers, ctx.env))),
|
|
204
|
-
...(only === undefined ? {} : { only }),
|
|
205
190
|
...(filter === undefined ? {} : { filter }),
|
|
206
191
|
...(type === undefined ? {} : { type }),
|
|
207
|
-
// `kept` is the corpus the
|
|
208
|
-
// `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
|
|
192
|
+
// `kept` is the corpus the run saw. `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
|
|
209
193
|
// set, and reporting the whole tree as its total would name a corpus no run ever had.
|
|
210
194
|
...(sample === undefined ? {} : { sample: { kept: files.length, total: selected.length } }),
|
|
211
195
|
// The fourth input to the split. Without it a failing shard's `fix:` re-splits the whole
|
package/src/cmd-verify.ts
CHANGED
|
@@ -20,15 +20,16 @@ import type { ParsedArgs } from './parse';
|
|
|
20
20
|
import { flagBool, flagString } from './parse';
|
|
21
21
|
import { WORKER_CEILING, WORKER_FLOOR } from './test-workers';
|
|
22
22
|
import { VERIFY_STEPS } from './verify-checks';
|
|
23
|
-
import { VerifyMergeInputError, VerifyShardInvalidError } from './verify-errors';
|
|
24
23
|
import { readVerifyFloor } from './verify-floor';
|
|
25
24
|
import type { VerifyPart } from './verify-merge';
|
|
26
|
-
import { mergeParts, parsePart } from './verify-merge';
|
|
25
|
+
import { coverageRiders, mergeParts, parsePart, VerifyMergeInputError } from './verify-merge';
|
|
26
|
+
import { stepStream } from './verify-progress';
|
|
27
27
|
import { runVerify } from './verify-run';
|
|
28
28
|
import type { ShardSpec, Timings } from './verify-shard';
|
|
29
|
-
import { assertShardable, parseShard, readTimings } from './verify-shard';
|
|
29
|
+
import { assertShardable, parseShard, readTimings, VerifyShardInvalidError } from './verify-shard';
|
|
30
30
|
import type { VerifyStepName } from './verify-step';
|
|
31
31
|
import { VERIFY_STEP_NAMES } from './verify-step';
|
|
32
|
+
import { writeErrorLine } from './write-line';
|
|
32
33
|
|
|
33
34
|
// One import path for the gate, unchanged by the split: `index.ts`, `x build` and the MCP host all
|
|
34
35
|
// reach the list and the runner through this module, and a second path to either would be the
|
|
@@ -55,6 +56,9 @@ export const verifyCommand: CliCommand = {
|
|
|
55
56
|
...(only === undefined ? {} : { only }),
|
|
56
57
|
...(shard === undefined ? {} : { shard }),
|
|
57
58
|
...(isolate === undefined ? {} : { isolate }),
|
|
59
|
+
// `--json` prints one document when the run ends; a line per finished step on stderr is
|
|
60
|
+
// what a cancelled CI job's log ends on (#589). stdout stays the single document.
|
|
61
|
+
...stepStream(ctx.args.json, writeErrorLine),
|
|
58
62
|
});
|
|
59
63
|
return shard === undefined ? result : withShardData(result, shard);
|
|
60
64
|
},
|
|
@@ -119,7 +123,10 @@ async function mergeCommand(
|
|
|
119
123
|
}
|
|
120
124
|
parts.push(parsePart(file, await handle.text()));
|
|
121
125
|
}
|
|
122
|
-
|
|
126
|
+
const floor = await readVerifyFloor(root);
|
|
127
|
+
return mergeParts(parts, floor, VERIFY_STEP_NAMES, {
|
|
128
|
+
riders: await coverageRiders(parts, root, floor),
|
|
129
|
+
});
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
/**
|
|
@@ -142,8 +149,9 @@ export const readOnlySteps = (args: ParsedArgs): readonly VerifyStepName[] | und
|
|
|
142
149
|
if (unknown.length === 0) {
|
|
143
150
|
return VERIFY_STEP_NAMES.filter((name) => items.includes(name));
|
|
144
151
|
}
|
|
152
|
+
// An empty item corrects to nothing — never to whichever step is nearest to ''.
|
|
145
153
|
const corrected = items.map((item) =>
|
|
146
|
-
isStepName(item) ? item : nearestName(item, VERIFY_STEP_NAMES),
|
|
154
|
+
isStepName(item) ? item : item === '' ? undefined : nearestName(item, VERIFY_STEP_NAMES),
|
|
147
155
|
);
|
|
148
156
|
const fix = corrected.every((item) => item !== undefined)
|
|
149
157
|
? `x verify --only ${renderFixShellArg([...new Set(corrected)].join(','), '<step,step>')} --json`
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A path read off the repository being scanned, made safe to ride in the `#` comment of a `fix:`
|
|
2
|
+
// line. One definition: every finding that names a scanned file in its fix writes it through this.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Control characters, `\u00xx`-escaped. The path is a value read off the repository being scanned,
|
|
6
|
+
* and it rides in a `#` comment: a directory holding a NEWLINE ends that comment, so everything
|
|
7
|
+
* after it is a second command in a line whose whole purpose is to be pasted into a shell.
|
|
8
|
+
* Escaped rather than deleted, because the comment still has to name the file the reader owns.
|
|
9
|
+
*/
|
|
10
|
+
export const commentSafe = (path: string): string =>
|
|
11
|
+
[...path]
|
|
12
|
+
.map((char) => {
|
|
13
|
+
const code = char.codePointAt(0) ?? 0;
|
|
14
|
+
return code < 0x20 || code === 0x7f ? `\\u${code.toString(16).padStart(4, '0')}` : char;
|
|
15
|
+
})
|
|
16
|
+
.join('');
|