@stndrds/cli 1.0.0-alpha.263 → 1.0.0-alpha.264
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/dist/bin.mjs +112 -715
- package/package.json +2 -4
- package/dist/assets/standards-drift.yml +0 -242
- package/dist/assets/standards-pull-skill.md +0 -101
package/dist/bin.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/program.ts
|
|
4
|
-
import
|
|
4
|
+
import chalk7 from "chalk";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
|
|
7
7
|
// src/client.ts
|
|
@@ -216,25 +216,6 @@ function formatOutput(data, format) {
|
|
|
216
216
|
`);
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
// src/commands/autofill.ts
|
|
220
|
-
function registerAutofillCommand(program) {
|
|
221
|
-
const autofill = program.command("autofill").description("AI attribute autofill (backfill empty record fields)");
|
|
222
|
-
autofill.command("backfill").description("Dry-run estimate (default) or enqueue an autofill backfill job").requiredOption("--objects <ids>", "comma-separated object IDs to backfill").option("--max-records <n>", "cap the number of records processed").option("--confirm", "enqueue the job instead of returning a dry-run estimate").action(
|
|
223
|
-
async (opts, cmd) => {
|
|
224
|
-
const objectIds = parseCsvList(opts.objects);
|
|
225
|
-
if (objectIds.length === 0) {
|
|
226
|
-
throw new Error("--objects requires at least one object ID");
|
|
227
|
-
}
|
|
228
|
-
const client = getClientFromCommand(cmd);
|
|
229
|
-
const body = { objectIds };
|
|
230
|
-
if (opts.maxRecords) body.maxRecords = Number(opts.maxRecords);
|
|
231
|
-
if (opts.confirm) body.confirm = true;
|
|
232
|
-
const result = await client.post("/autofill/backfill", body);
|
|
233
|
-
formatOutput(result, getFormat(cmd));
|
|
234
|
-
}
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
219
|
// src/commands/bundles.ts
|
|
239
220
|
function registerBundlesCommand(program) {
|
|
240
221
|
const bundles = program.command("bundles").description("Manage system bundles assigned to the current tenant (admin)");
|
|
@@ -305,354 +286,6 @@ function registerConnectorsCommand(program) {
|
|
|
305
286
|
});
|
|
306
287
|
}
|
|
307
288
|
|
|
308
|
-
// src/commands/diff.ts
|
|
309
|
-
import chalk4 from "chalk";
|
|
310
|
-
|
|
311
|
-
// src/drift/engine.ts
|
|
312
|
-
import { createHash } from "crypto";
|
|
313
|
-
import { ValidationError, canonicalStringify, viewSyncPayload } from "@stndrds/schema";
|
|
314
|
-
var SUPPORTED_HASH_VERSION = 1;
|
|
315
|
-
var CONFIG_SUBKEYS = ["tabs", "defaultFilters", "sidePanel"];
|
|
316
|
-
function viewSyncHashOf(view) {
|
|
317
|
-
return createHash("sha256").update(canonicalStringify(viewSyncPayload(view))).digest("hex");
|
|
318
|
-
}
|
|
319
|
-
function viewKey(object, name, type) {
|
|
320
|
-
return `${object}:${name}:${type}`;
|
|
321
|
-
}
|
|
322
|
-
function classifyViewDrift(input2) {
|
|
323
|
-
const { codeHash, dbHash, baselineHash, resolutions } = input2;
|
|
324
|
-
if (baselineHash === null) {
|
|
325
|
-
return codeHash === dbHash ? "in-sync" : "runtime-diverged";
|
|
326
|
-
}
|
|
327
|
-
const codeMoved = codeHash !== baselineHash;
|
|
328
|
-
const dbMoved = dbHash !== baselineHash;
|
|
329
|
-
if (!(codeMoved || dbMoved)) {
|
|
330
|
-
return "in-sync";
|
|
331
|
-
}
|
|
332
|
-
if (codeMoved && !dbMoved) {
|
|
333
|
-
return "code-changed";
|
|
334
|
-
}
|
|
335
|
-
if (!codeMoved && dbMoved) {
|
|
336
|
-
return "runtime-diverged";
|
|
337
|
-
}
|
|
338
|
-
const hasExactResolution = resolutions.some(
|
|
339
|
-
(resolution) => resolution.codeHash === codeHash && resolution.dbHash === dbHash
|
|
340
|
-
);
|
|
341
|
-
return hasExactResolution ? "conflict-resolved" : "conflict";
|
|
342
|
-
}
|
|
343
|
-
function computeChangedFields(localView, entry) {
|
|
344
|
-
const changedFields = [];
|
|
345
|
-
if (canonicalStringify(localView.label) !== canonicalStringify(entry.label)) {
|
|
346
|
-
changedFields.push("label");
|
|
347
|
-
}
|
|
348
|
-
if ((localView.description ?? null) !== entry.description) {
|
|
349
|
-
changedFields.push("description");
|
|
350
|
-
}
|
|
351
|
-
if ((localView.icon ?? null) !== entry.icon) {
|
|
352
|
-
changedFields.push("icon");
|
|
353
|
-
}
|
|
354
|
-
if ((localView.default ?? false) !== entry.default) {
|
|
355
|
-
changedFields.push("default");
|
|
356
|
-
}
|
|
357
|
-
if (canonicalStringify(localView.metadata ?? null) !== canonicalStringify(entry.metadata)) {
|
|
358
|
-
changedFields.push("metadata");
|
|
359
|
-
}
|
|
360
|
-
const localConfig = localView.config;
|
|
361
|
-
const dbConfig = entry.config;
|
|
362
|
-
for (const key of CONFIG_SUBKEYS) {
|
|
363
|
-
const localValue = localConfig[key] ?? null;
|
|
364
|
-
const dbValue = dbConfig[key] ?? null;
|
|
365
|
-
if (canonicalStringify(localValue) !== canonicalStringify(dbValue)) {
|
|
366
|
-
changedFields.push(`config.${key}`);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
return changedFields;
|
|
370
|
-
}
|
|
371
|
-
function computeDriftReport(local, state) {
|
|
372
|
-
if (state.hashVersion !== SUPPORTED_HASH_VERSION) {
|
|
373
|
-
throw new ValidationError(
|
|
374
|
-
`Server hash version ${state.hashVersion} is not supported by this CLI (expected ${SUPPORTED_HASH_VERSION}). Upgrade the CLI or the server so both sides compute comparable hashes.`,
|
|
375
|
-
[]
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
const stateByKey = /* @__PURE__ */ new Map();
|
|
379
|
-
for (const entry of state.views) {
|
|
380
|
-
stateByKey.set(viewKey(entry.object, entry.name, entry.type), entry);
|
|
381
|
-
}
|
|
382
|
-
const localKeys = /* @__PURE__ */ new Set();
|
|
383
|
-
const views = [];
|
|
384
|
-
const created = [];
|
|
385
|
-
for (const localView of local.views) {
|
|
386
|
-
const key = viewKey(localView.object, localView.name, localView.type);
|
|
387
|
-
localKeys.add(key);
|
|
388
|
-
const entry = stateByKey.get(key);
|
|
389
|
-
if (!entry) {
|
|
390
|
-
created.push({ key, object: localView.object, name: localView.name, type: localView.type });
|
|
391
|
-
continue;
|
|
392
|
-
}
|
|
393
|
-
const codeHash = viewSyncHashOf(localView);
|
|
394
|
-
const driftState = classifyViewDrift({
|
|
395
|
-
codeHash,
|
|
396
|
-
dbHash: entry.dbHash,
|
|
397
|
-
baselineHash: entry.baselineHash,
|
|
398
|
-
resolutions: entry.resolutions
|
|
399
|
-
});
|
|
400
|
-
views.push({
|
|
401
|
-
key,
|
|
402
|
-
type: localView.type,
|
|
403
|
-
state: driftState,
|
|
404
|
-
viewId: entry.viewId,
|
|
405
|
-
codeHash,
|
|
406
|
-
dbHash: entry.dbHash,
|
|
407
|
-
changedFields: computeChangedFields(localView, entry)
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
const orphans = state.views.filter((entry) => !localKeys.has(viewKey(entry.object, entry.name, entry.type))).map((entry) => ({
|
|
411
|
-
key: viewKey(entry.object, entry.name, entry.type),
|
|
412
|
-
viewId: entry.viewId,
|
|
413
|
-
object: entry.object,
|
|
414
|
-
name: entry.name,
|
|
415
|
-
type: entry.type
|
|
416
|
-
}));
|
|
417
|
-
const hasUnresolvedConflict = views.some((view) => view.state === "conflict");
|
|
418
|
-
const exitCode = hasUnresolvedConflict || state.drift.hasUnexpectedDrift ? 1 : 0;
|
|
419
|
-
return { views, created, orphans, schema: state.drift, exitCode };
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// src/schema-loader.ts
|
|
423
|
-
import { dirname, resolve } from "path";
|
|
424
|
-
import { ValidationError as ValidationError2 } from "@stndrds/schema";
|
|
425
|
-
import * as esbuild from "esbuild";
|
|
426
|
-
async function loadLocalRegistry(entryPath) {
|
|
427
|
-
const absoluteEntryPath = resolve(entryPath);
|
|
428
|
-
const virtualEntry = [
|
|
429
|
-
`import ${JSON.stringify(absoluteEntryPath)};`,
|
|
430
|
-
`export { registry, viewRegistry } from "@stndrds/schema";`
|
|
431
|
-
].join("\n");
|
|
432
|
-
let bundledCode;
|
|
433
|
-
try {
|
|
434
|
-
const result = await esbuild.build({
|
|
435
|
-
stdin: {
|
|
436
|
-
contents: virtualEntry,
|
|
437
|
-
resolveDir: dirname(absoluteEntryPath),
|
|
438
|
-
loader: "ts",
|
|
439
|
-
sourcefile: "standards-diff-virtual-entry.ts"
|
|
440
|
-
},
|
|
441
|
-
bundle: true,
|
|
442
|
-
platform: "node",
|
|
443
|
-
format: "esm",
|
|
444
|
-
write: false,
|
|
445
|
-
external: ["node:*"],
|
|
446
|
-
logLevel: "silent"
|
|
447
|
-
});
|
|
448
|
-
const outputFile = result.outputFiles[0];
|
|
449
|
-
if (!outputFile) {
|
|
450
|
-
throw entryLoadError(absoluteEntryPath, new Error("esbuild produced no output"));
|
|
451
|
-
}
|
|
452
|
-
bundledCode = outputFile.text;
|
|
453
|
-
} catch (error) {
|
|
454
|
-
throw entryLoadError(absoluteEntryPath, error);
|
|
455
|
-
}
|
|
456
|
-
let loadedModule;
|
|
457
|
-
try {
|
|
458
|
-
const dataUrl = `data:text/javascript;base64,${Buffer.from(bundledCode, "utf-8").toString("base64")}`;
|
|
459
|
-
loadedModule = await import(dataUrl);
|
|
460
|
-
} catch (error) {
|
|
461
|
-
throw entryLoadError(absoluteEntryPath, error);
|
|
462
|
-
}
|
|
463
|
-
return {
|
|
464
|
-
objects: loadedModule.registry.getAll(),
|
|
465
|
-
views: loadedModule.viewRegistry.getAll()
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
function entryLoadError(entryPath, cause) {
|
|
469
|
-
if (cause instanceof ValidationError2) {
|
|
470
|
-
return cause;
|
|
471
|
-
}
|
|
472
|
-
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
|
473
|
-
return new ValidationError2(
|
|
474
|
-
`Schema entry failed to load in isolation: ${entryPath}
|
|
475
|
-
${causeMessage}`,
|
|
476
|
-
[]
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// src/standards-config.ts
|
|
481
|
-
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
482
|
-
import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
|
|
483
|
-
import { ValidationError as ValidationError3 } from "@stndrds/schema";
|
|
484
|
-
|
|
485
|
-
// src/standards-dir.ts
|
|
486
|
-
import { existsSync } from "fs";
|
|
487
|
-
import { join } from "path";
|
|
488
|
-
function resolveStandardsDir(startDir = process.cwd()) {
|
|
489
|
-
let dir = startDir;
|
|
490
|
-
while (true) {
|
|
491
|
-
if (existsSync(join(dir, "package.json"))) {
|
|
492
|
-
return join(dir, ".standards");
|
|
493
|
-
}
|
|
494
|
-
const parent = join(dir, "..");
|
|
495
|
-
if (parent === dir) break;
|
|
496
|
-
dir = parent;
|
|
497
|
-
}
|
|
498
|
-
return join(startDir, ".standards");
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// src/standards-config.ts
|
|
502
|
-
function readStandardsProjectConfig(startDir = process.cwd()) {
|
|
503
|
-
const standardsDir = resolveStandardsDir(startDir);
|
|
504
|
-
const projectRoot = dirname2(standardsDir);
|
|
505
|
-
const configPath = join2(standardsDir, "config.json");
|
|
506
|
-
if (!existsSync2(configPath)) {
|
|
507
|
-
throw new ValidationError3(
|
|
508
|
-
`No Standards project config found at ${configPath}. Run "standards init" to create one.`,
|
|
509
|
-
[]
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
|
-
const raw = readFileSync(configPath, "utf-8");
|
|
513
|
-
let parsed;
|
|
514
|
-
try {
|
|
515
|
-
parsed = JSON.parse(raw);
|
|
516
|
-
} catch (error) {
|
|
517
|
-
const causeMessage = error instanceof Error ? error.message : String(error);
|
|
518
|
-
throw new ValidationError3(`${configPath} is not valid JSON: ${causeMessage}`, []);
|
|
519
|
-
}
|
|
520
|
-
if (typeof parsed.schemaEntry !== "string" || parsed.schemaEntry.length === 0) {
|
|
521
|
-
throw new ValidationError3(
|
|
522
|
-
`${configPath} is missing required "schemaEntry" field. Run "standards init" to (re)create it.`,
|
|
523
|
-
[]
|
|
524
|
-
);
|
|
525
|
-
}
|
|
526
|
-
return {
|
|
527
|
-
schemaEntry: resolve2(projectRoot, parsed.schemaEntry)
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// src/commands/diff.ts
|
|
532
|
-
async function runDiffCommand(deps) {
|
|
533
|
-
const loadRegistry = deps.loadRegistry ?? loadLocalRegistry;
|
|
534
|
-
let local;
|
|
535
|
-
let state;
|
|
536
|
-
try {
|
|
537
|
-
local = await loadRegistry(deps.schemaEntry);
|
|
538
|
-
state = await deps.client.get("/schema/state");
|
|
539
|
-
} catch (error) {
|
|
540
|
-
return { exitCode: 2, errorMessage: messageOf(error) };
|
|
541
|
-
}
|
|
542
|
-
try {
|
|
543
|
-
const report = computeDriftReport(local, state);
|
|
544
|
-
return { exitCode: report.exitCode, report, state };
|
|
545
|
-
} catch (error) {
|
|
546
|
-
return { exitCode: 2, errorMessage: messageOf(error) };
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
function renderCreatedLine(view) {
|
|
550
|
-
return chalk4.dim(`+ view ${view.key} \u2014 will be created`);
|
|
551
|
-
}
|
|
552
|
-
function renderOrphanLine(view) {
|
|
553
|
-
return chalk4.dim(`\u25CB view ${view.key} \u2014 removed from code, still in DB`);
|
|
554
|
-
}
|
|
555
|
-
function renderViewLine(entry) {
|
|
556
|
-
const fields = entry.changedFields.join(", ");
|
|
557
|
-
if (entry.state === "code-changed") {
|
|
558
|
-
return chalk4.green(`~ view ${entry.key} \u2014 will apply on next deploy (code changed: ${fields})`);
|
|
559
|
-
}
|
|
560
|
-
if (entry.state === "runtime-diverged") {
|
|
561
|
-
return chalk4.yellow(
|
|
562
|
-
`\u2260 view ${entry.key} \u2014 runtime divergence (users changed: ${fields}) \u2014 promote or leave`
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
if (entry.state === "conflict") {
|
|
566
|
-
return chalk4.red(
|
|
567
|
-
`\u26A0 CONFLICT view ${entry.key} \u2014 next deploy WOULD DESTROY runtime changes (${fields}) \u2014 run "standards pull"`
|
|
568
|
-
);
|
|
569
|
-
}
|
|
570
|
-
return chalk4.green(`\u2713 resolved conflict view ${entry.key} \u2014 code will apply on next deploy`);
|
|
571
|
-
}
|
|
572
|
-
function relationTag(attr) {
|
|
573
|
-
return attr.isRelation ? " [relation]" : "";
|
|
574
|
-
}
|
|
575
|
-
function attrInline(attr) {
|
|
576
|
-
const suffix = attr.tolerated ? ", tolerated" : `${relationTag(attr)}, label: "${attr.label}"`;
|
|
577
|
-
return ` \xB7 ${attr.name} (${attr.type}${suffix})`;
|
|
578
|
-
}
|
|
579
|
-
function renderSchemaDriftLines(schema) {
|
|
580
|
-
const lines = [];
|
|
581
|
-
for (const obj of schema.customObjects) {
|
|
582
|
-
lines.push(chalk4.yellow(`\u26A0 ${obj.name} (${obj.label}) \u2014 custom object, not in code`));
|
|
583
|
-
lines.push(chalk4.dim(' \u2192 Run "standards pull" to promote or leave.'));
|
|
584
|
-
}
|
|
585
|
-
for (const entry of schema.systemObjectDrift) {
|
|
586
|
-
const unexpected = entry.sealed ? entry.customAttributes.filter((a) => !a.tolerated) : [];
|
|
587
|
-
const tolerated = entry.customAttributes.filter((a) => a.tolerated);
|
|
588
|
-
if (entry.sealed && unexpected.length > 0) {
|
|
589
|
-
lines.push(
|
|
590
|
-
chalk4.red(
|
|
591
|
-
`\u2717 ${entry.objectName} \u2014 ${unexpected.length} unexpected custom attribute(s) (sealed object)`
|
|
592
|
-
)
|
|
593
|
-
);
|
|
594
|
-
for (const a of unexpected) lines.push(chalk4.red(attrInline(a)));
|
|
595
|
-
lines.push(chalk4.dim(' \u2192 Run "standards pull" to promote or tolerate these attributes.'));
|
|
596
|
-
if (tolerated.length > 0) {
|
|
597
|
-
lines.push(chalk4.dim(` Also tolerated: ${tolerated.length} attribute(s)`));
|
|
598
|
-
for (const a of tolerated) lines.push(chalk4.dim(attrInline(a)));
|
|
599
|
-
}
|
|
600
|
-
continue;
|
|
601
|
-
}
|
|
602
|
-
const okAttrs = entry.sealed ? tolerated : entry.customAttributes;
|
|
603
|
-
if (okAttrs.length === 0) continue;
|
|
604
|
-
const okSuffix = entry.sealed ? "tolerated custom attribute(s) (sealed)" : "custom attribute(s) (extensible)";
|
|
605
|
-
lines.push(chalk4.green(`\u2713 ${entry.objectName} \u2014 ${okAttrs.length} ${okSuffix}`));
|
|
606
|
-
for (const a of okAttrs) lines.push(chalk4.dim(attrInline(a)));
|
|
607
|
-
}
|
|
608
|
-
return lines;
|
|
609
|
-
}
|
|
610
|
-
function renderDiffLines(report) {
|
|
611
|
-
const lines = [];
|
|
612
|
-
for (const view of report.created) lines.push(renderCreatedLine(view));
|
|
613
|
-
for (const view of report.views) {
|
|
614
|
-
if (view.state === "in-sync") continue;
|
|
615
|
-
lines.push(renderViewLine(view));
|
|
616
|
-
}
|
|
617
|
-
for (const view of report.orphans) lines.push(renderOrphanLine(view));
|
|
618
|
-
lines.push(...renderSchemaDriftLines(report.schema));
|
|
619
|
-
return lines;
|
|
620
|
-
}
|
|
621
|
-
function formatDiffReport(report, format) {
|
|
622
|
-
if (format === "json") {
|
|
623
|
-
return JSON.stringify(report, null, 2);
|
|
624
|
-
}
|
|
625
|
-
const lines = renderDiffLines(report);
|
|
626
|
-
return lines.length > 0 ? lines.join("\n") : chalk4.green("\u2713 No drift detected \u2014 next deploy is a no-op.");
|
|
627
|
-
}
|
|
628
|
-
function resolveEffectiveFormat(cmd) {
|
|
629
|
-
const format = getFormat(cmd);
|
|
630
|
-
const formatExplicit = cmd.getOptionValueSourceWithGlobals("format") !== "default";
|
|
631
|
-
return format === "json" && !formatExplicit ? "table" : format;
|
|
632
|
-
}
|
|
633
|
-
function registerDiffCommand(program) {
|
|
634
|
-
program.command("diff").description("Show what the next deploy will do to the runtime schema and views").action(async (_opts, cmd) => {
|
|
635
|
-
let client;
|
|
636
|
-
let schemaEntry;
|
|
637
|
-
try {
|
|
638
|
-
client = getClientFromCommand(cmd);
|
|
639
|
-
schemaEntry = readStandardsProjectConfig().schemaEntry;
|
|
640
|
-
} catch (error) {
|
|
641
|
-
console.error(chalk4.red(`\u2717 ${messageOf(error)}`));
|
|
642
|
-
process.exit(2);
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
const result = await runDiffCommand({ client, schemaEntry });
|
|
646
|
-
if (result.exitCode === 2) {
|
|
647
|
-
console.error(chalk4.red(`\u2717 ${result.errorMessage}`));
|
|
648
|
-
process.exit(2);
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
console.info(formatDiffReport(result.report, resolveEffectiveFormat(cmd)));
|
|
652
|
-
process.exit(result.exitCode);
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
|
|
656
289
|
// src/commands/documents.ts
|
|
657
290
|
var BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"];
|
|
658
291
|
function formatFileSize(bytes) {
|
|
@@ -770,101 +403,8 @@ function registerFoldersCommand(program) {
|
|
|
770
403
|
});
|
|
771
404
|
}
|
|
772
405
|
|
|
773
|
-
// src/commands/init.ts
|
|
774
|
-
import { copyFileSync, existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
775
|
-
import { dirname as dirname3, join as join3, relative, resolve as resolve3 } from "path";
|
|
776
|
-
import { fileURLToPath } from "url";
|
|
777
|
-
import { ValidationError as ValidationError4 } from "@stndrds/schema";
|
|
778
|
-
import chalk5 from "chalk";
|
|
779
|
-
var PROMOTIONS_GITIGNORE_LINE = ".standards/promotions/";
|
|
780
|
-
function resolveAssetPath(filename) {
|
|
781
|
-
const moduleDir = dirname3(fileURLToPath(import.meta.url));
|
|
782
|
-
const candidates = [
|
|
783
|
-
join3(moduleDir, "..", "assets", filename),
|
|
784
|
-
join3(moduleDir, "assets", filename)
|
|
785
|
-
];
|
|
786
|
-
const found = candidates.find((candidate) => existsSync3(candidate));
|
|
787
|
-
if (!found) {
|
|
788
|
-
throw new ValidationError4(
|
|
789
|
-
`Could not locate bundled asset "${filename}" (checked: ${candidates.join(", ")}). This is a packaging bug in @stndrds/cli.`,
|
|
790
|
-
[]
|
|
791
|
-
);
|
|
792
|
-
}
|
|
793
|
-
return found;
|
|
794
|
-
}
|
|
795
|
-
async function runInitCommand(options, deps = {}) {
|
|
796
|
-
const cwd = deps.cwd ?? process.cwd();
|
|
797
|
-
await loadLocalRegistry(options.entry);
|
|
798
|
-
const standardsDir = resolveStandardsDir(cwd);
|
|
799
|
-
const projectRoot = dirname3(standardsDir);
|
|
800
|
-
const entryAbsolute = resolve3(cwd, options.entry);
|
|
801
|
-
const schemaEntry = relative(projectRoot, entryAbsolute);
|
|
802
|
-
mkdirSync(standardsDir, { recursive: true });
|
|
803
|
-
const configPath = join3(standardsDir, "config.json");
|
|
804
|
-
writeFileSync(configPath, `${JSON.stringify({ schemaEntry }, null, 2)}
|
|
805
|
-
`, "utf-8");
|
|
806
|
-
const gitignorePath = join3(projectRoot, ".gitignore");
|
|
807
|
-
const gitignore = updateGitignore(gitignorePath);
|
|
808
|
-
const skillDir = join3(projectRoot, ".claude", "skills", "standards-pull");
|
|
809
|
-
mkdirSync(skillDir, { recursive: true });
|
|
810
|
-
const skillPath = join3(skillDir, "SKILL.md");
|
|
811
|
-
copyFileSync(resolveAssetPath("standards-pull-skill.md"), skillPath);
|
|
812
|
-
let workflowPath;
|
|
813
|
-
if (options.ci) {
|
|
814
|
-
const workflowDir = join3(projectRoot, ".github", "workflows");
|
|
815
|
-
mkdirSync(workflowDir, { recursive: true });
|
|
816
|
-
workflowPath = join3(workflowDir, "standards-drift.yml");
|
|
817
|
-
copyFileSync(resolveAssetPath("standards-drift.yml"), workflowPath);
|
|
818
|
-
}
|
|
819
|
-
return { standardsDir, configPath, skillPath, gitignore, workflowPath };
|
|
820
|
-
}
|
|
821
|
-
function updateGitignore(gitignorePath) {
|
|
822
|
-
if (!existsSync3(gitignorePath)) {
|
|
823
|
-
return "not-found";
|
|
824
|
-
}
|
|
825
|
-
const content = readFileSync2(gitignorePath, "utf-8");
|
|
826
|
-
const alreadyPresent = content.split("\n").some((line) => line.trim() === PROMOTIONS_GITIGNORE_LINE);
|
|
827
|
-
if (alreadyPresent) {
|
|
828
|
-
return "already-present";
|
|
829
|
-
}
|
|
830
|
-
const withTrailingNewline = content.length === 0 || content.endsWith("\n") ? content : `${content}
|
|
831
|
-
`;
|
|
832
|
-
writeFileSync(gitignorePath, `${withTrailingNewline}${PROMOTIONS_GITIGNORE_LINE}
|
|
833
|
-
`, "utf-8");
|
|
834
|
-
return "updated";
|
|
835
|
-
}
|
|
836
|
-
function registerInitCommand(program) {
|
|
837
|
-
program.command("init").description(
|
|
838
|
-
"Bootstrap this project: .standards/config.json, the standards-pull skill, and (optionally) a CI drift check. Re-running restores the managed skill/workflow assets verbatim (overwriting local edits to them); .gitignore is the only step that's merge-safe."
|
|
839
|
-
).requiredOption("--entry <path>", "Path to the schema entry file (e.g. src/schema/index.ts)").option("--ci", "Also write .github/workflows/standards-drift.yml").action(async (opts) => {
|
|
840
|
-
let result;
|
|
841
|
-
try {
|
|
842
|
-
result = await runInitCommand({ entry: opts.entry, ci: opts.ci });
|
|
843
|
-
} catch (error) {
|
|
844
|
-
console.error(chalk5.red(`\u2717 ${messageOf(error)}`));
|
|
845
|
-
process.exit(2);
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
console.info(chalk5.green(`\u2713 Wrote ${result.configPath}`));
|
|
849
|
-
console.info(chalk5.green(`\u2713 Installed skill at ${result.skillPath}`));
|
|
850
|
-
if (result.gitignore === "updated") {
|
|
851
|
-
console.info(chalk5.green(`\u2713 Added "${PROMOTIONS_GITIGNORE_LINE}" to .gitignore`));
|
|
852
|
-
} else if (result.gitignore === "not-found") {
|
|
853
|
-
console.info(
|
|
854
|
-
chalk5.dim(
|
|
855
|
-
` No .gitignore found \u2014 remember to ignore "${PROMOTIONS_GITIGNORE_LINE}" yourself.`
|
|
856
|
-
)
|
|
857
|
-
);
|
|
858
|
-
}
|
|
859
|
-
if (result.workflowPath) {
|
|
860
|
-
console.info(chalk5.green(`\u2713 Wrote ${result.workflowPath}`));
|
|
861
|
-
}
|
|
862
|
-
console.info(chalk5.dim('Run "standards diff" to check for drift.'));
|
|
863
|
-
});
|
|
864
|
-
}
|
|
865
|
-
|
|
866
406
|
// src/commands/keys.ts
|
|
867
|
-
import
|
|
407
|
+
import chalk4 from "chalk";
|
|
868
408
|
function registerKeysCommand(program) {
|
|
869
409
|
const keys = program.command("keys").description("Manage Standards API keys");
|
|
870
410
|
keys.command("list").description("List API keys").action(async (_opts, cmd) => {
|
|
@@ -891,267 +431,120 @@ function registerKeysCommand(program) {
|
|
|
891
431
|
}
|
|
892
432
|
const client = getClientFromCommand(cmd);
|
|
893
433
|
await client.delete(`/api-keys/${id}`);
|
|
894
|
-
process.stdout.write(`${
|
|
434
|
+
process.stdout.write(`${chalk4.green("\u2713")} API key ${id} revoked.
|
|
895
435
|
`);
|
|
896
436
|
});
|
|
897
437
|
}
|
|
898
438
|
|
|
899
439
|
// src/commands/pull.ts
|
|
900
|
-
import
|
|
901
|
-
import { dirname as dirname4, join as join4 } from "path";
|
|
902
|
-
import * as prompts from "@clack/prompts";
|
|
903
|
-
import chalk7 from "chalk";
|
|
904
|
-
function handlePromote(entry, ctx) {
|
|
905
|
-
const viewState = ctx.viewStateByKey.get(entry.key);
|
|
906
|
-
if (!viewState) {
|
|
907
|
-
return {
|
|
908
|
-
ok: false,
|
|
909
|
-
message: `No runtime state found for view "${entry.key}" \u2014 cannot promote. The pull context must be built from the same schema state as the drift report.`
|
|
910
|
-
};
|
|
911
|
-
}
|
|
912
|
-
try {
|
|
913
|
-
const runtimeConfig = {
|
|
914
|
-
object: viewState.object,
|
|
915
|
-
name: viewState.name,
|
|
916
|
-
type: viewState.type,
|
|
917
|
-
label: viewState.label,
|
|
918
|
-
description: viewState.description,
|
|
919
|
-
icon: viewState.icon,
|
|
920
|
-
default: viewState.default,
|
|
921
|
-
metadata: viewState.metadata,
|
|
922
|
-
config: viewState.config
|
|
923
|
-
};
|
|
924
|
-
const builderHint = findBuilderFileHint(
|
|
925
|
-
ctx.schemaEntry,
|
|
926
|
-
viewState.object,
|
|
927
|
-
viewState.name,
|
|
928
|
-
viewState.type
|
|
929
|
-
);
|
|
930
|
-
const promotionsDir = join4(ctx.standardsDir, "promotions");
|
|
931
|
-
mkdirSync2(promotionsDir, { recursive: true });
|
|
932
|
-
const filePath = join4(
|
|
933
|
-
promotionsDir,
|
|
934
|
-
`${viewState.object}-${viewState.name}-${viewState.type}.md`
|
|
935
|
-
);
|
|
936
|
-
const content = [
|
|
937
|
-
`# Promote view \`${entry.key}\``,
|
|
938
|
-
"",
|
|
939
|
-
"Runtime users changed this view since it was last synced from code. To keep",
|
|
940
|
-
"their changes, fold this configuration into the view's builder call below.",
|
|
941
|
-
"",
|
|
942
|
-
"## Runtime config",
|
|
943
|
-
"",
|
|
944
|
-
"```json",
|
|
945
|
-
JSON.stringify(runtimeConfig, null, 2),
|
|
946
|
-
"```",
|
|
947
|
-
"",
|
|
948
|
-
"## Builder file",
|
|
949
|
-
"",
|
|
950
|
-
builderHint,
|
|
951
|
-
"",
|
|
952
|
-
"---",
|
|
953
|
-
"",
|
|
954
|
-
"Hand this file to your agent, then re-run `standards diff`.",
|
|
955
|
-
""
|
|
956
|
-
].join("\n");
|
|
957
|
-
writeFileSync2(filePath, content, "utf-8");
|
|
958
|
-
return { ok: true, filePath };
|
|
959
|
-
} catch (error) {
|
|
960
|
-
return {
|
|
961
|
-
ok: false,
|
|
962
|
-
message: `Failed to write promotion file for ${entry.key}: ${messageOf(error)}`
|
|
963
|
-
};
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
async function handleOverwrite(entry, _ctx, client) {
|
|
967
|
-
try {
|
|
968
|
-
await client.post("/schema/resolutions", {
|
|
969
|
-
viewId: entry.viewId,
|
|
970
|
-
codeHash: entry.codeHash,
|
|
971
|
-
dbHash: entry.dbHash
|
|
972
|
-
});
|
|
973
|
-
return { ok: true };
|
|
974
|
-
} catch (error) {
|
|
975
|
-
if (error instanceof ApiClientError && error.statusCode === 400) {
|
|
976
|
-
return {
|
|
977
|
-
ok: false,
|
|
978
|
-
reason: "stale",
|
|
979
|
-
message: `View ${entry.key} changed since the diff \u2014 re-run "standards diff".`
|
|
980
|
-
};
|
|
981
|
-
}
|
|
982
|
-
return {
|
|
983
|
-
ok: false,
|
|
984
|
-
reason: "infra",
|
|
985
|
-
message: `Failed to overwrite ${entry.key}: ${messageOf(error)}`
|
|
986
|
-
};
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
var BUILDER_SOURCE_EXTENSIONS = [".ts", ".tsx"];
|
|
990
|
-
var BUILDER_SEARCH_IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
|
|
991
|
-
function collectSourceFiles(dir) {
|
|
992
|
-
let entries;
|
|
993
|
-
try {
|
|
994
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
995
|
-
} catch {
|
|
996
|
-
return [];
|
|
997
|
-
}
|
|
998
|
-
const files = [];
|
|
999
|
-
for (const entry of entries) {
|
|
1000
|
-
if (BUILDER_SEARCH_IGNORED_DIRS.has(entry.name)) continue;
|
|
1001
|
-
const fullPath = join4(dir, entry.name);
|
|
1002
|
-
if (entry.isDirectory()) {
|
|
1003
|
-
files.push(...collectSourceFiles(fullPath));
|
|
1004
|
-
} else if (BUILDER_SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
|
|
1005
|
-
files.push(fullPath);
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
return files;
|
|
1009
|
-
}
|
|
1010
|
-
function escapeRegExp(value) {
|
|
1011
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1012
|
-
}
|
|
1013
|
-
function findBuilderFileHint(schemaEntry, object, name, type) {
|
|
1014
|
-
const builderCall = type === "detail" ? "detailView" : "listView";
|
|
1015
|
-
const pattern = new RegExp(`${builderCall}\\(\\s*["'\`]${escapeRegExp(name)}["'\`]`);
|
|
1016
|
-
const searchDir = dirname4(schemaEntry);
|
|
1017
|
-
for (const filePath of collectSourceFiles(searchDir)) {
|
|
1018
|
-
const lines = readFileSync3(filePath, "utf-8").split("\n");
|
|
1019
|
-
const lineIndex = lines.findIndex((line) => pattern.test(line));
|
|
1020
|
-
if (lineIndex !== -1) {
|
|
1021
|
-
return `\`${filePath}:${lineIndex + 1}\`
|
|
440
|
+
import chalk5 from "chalk";
|
|
1022
441
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
return
|
|
442
|
+
// src/drift/reconciliation-prompt.ts
|
|
443
|
+
var PREAMBLE = [
|
|
444
|
+
"# Standards schema reconciliation",
|
|
445
|
+
"",
|
|
446
|
+
"The deployed runtime has drifted from code. Walk each item **with the developer**,",
|
|
447
|
+
"then edit the fluent builders. The pre-deploy gate (STANDARDS_SYNC_CHECK=1) applies",
|
|
448
|
+
"your choices at deploy. Loop until the gate reports clean. Never make a destructive",
|
|
449
|
+
"choice without the developer's explicit confirmation.",
|
|
450
|
+
""
|
|
451
|
+
].join("\n");
|
|
452
|
+
function isViewDiverged(view) {
|
|
453
|
+
return view.baselineHash !== null && view.dbHash !== view.baselineHash;
|
|
454
|
+
}
|
|
455
|
+
function hasReconcilableDrift(state) {
|
|
456
|
+
return state.drift.customObjects.length > 0 || state.drift.systemObjectDrift.length > 0 || state.views.some(isViewDiverged);
|
|
457
|
+
}
|
|
458
|
+
function renderAttribute(objectName, sealed, attr) {
|
|
459
|
+
const action = attr.tolerated ? "Already tolerated \u2014 declare it in the builder to make it a system attribute, or leave tolerated." : `Choose: **declare** \`${attr.name}\` on the \`${objectName}\` builder (\u2192 becomes system on next sync), OR **tolerate** it \u2014 add \`.tolerate(["${attr.name}"])\`${sealed ? " (required: the object is sealed)" : ""}.`;
|
|
460
|
+
return `- \`${objectName}.${attr.name}\` (${attr.type})
|
|
461
|
+
${action}`;
|
|
462
|
+
}
|
|
463
|
+
function renderView(view) {
|
|
464
|
+
const d = view.delta;
|
|
465
|
+
const lines = [`### view \`${view.object}:${view.name}:${view.type}\``];
|
|
466
|
+
if (d) {
|
|
467
|
+
if (d.tabsAdded.length) lines.push(`- tabs added at runtime: ${d.tabsAdded.join(", ")}`);
|
|
468
|
+
if (d.tabsRemoved.length) lines.push(`- tabs removed at runtime: ${d.tabsRemoved.join(", ")}`);
|
|
469
|
+
for (const t of d.tabsChanged)
|
|
470
|
+
lines.push(`- tab "${t.name}" changed: ${t.changedKeys.join(", ")}`);
|
|
471
|
+
if (d.topLevelChanged.length) lines.push(`- changed: ${d.topLevelChanged.join(", ")}`);
|
|
472
|
+
}
|
|
473
|
+
lines.push(
|
|
474
|
+
"",
|
|
475
|
+
"Runtime config to fold into the builder call (or record an explicit resolution to let code win):",
|
|
476
|
+
"```json",
|
|
477
|
+
JSON.stringify(view.config, null, 2),
|
|
478
|
+
"```"
|
|
479
|
+
);
|
|
480
|
+
return lines.join("\n");
|
|
1035
481
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
482
|
+
function renderReconciliationPrompt(state) {
|
|
483
|
+
if (!hasReconcilableDrift(state))
|
|
484
|
+
return `${PREAMBLE}
|
|
485
|
+
No drift to reconcile \u2014 the runtime matches code.
|
|
486
|
+
`;
|
|
487
|
+
const parts = [PREAMBLE];
|
|
488
|
+
for (const obj of state.drift.customObjects) {
|
|
489
|
+
parts.push(
|
|
490
|
+
`## Custom object \`${obj.name}\` (runtime-only)
|
|
491
|
+
Add an \`object("${obj.name}")\` builder, then its attributes:`
|
|
492
|
+
);
|
|
493
|
+
for (const a of obj.attributes) parts.push(renderAttribute(obj.name, false, a));
|
|
1040
494
|
}
|
|
1041
|
-
const
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
const ctx = {
|
|
1046
|
-
viewStateByKey,
|
|
1047
|
-
schemaEntry: deps.schemaEntry,
|
|
1048
|
-
standardsDir: deps.standardsDir
|
|
1049
|
-
};
|
|
1050
|
-
const outcomes = [];
|
|
1051
|
-
for (const entry of entriesNeedingDecision) {
|
|
1052
|
-
const decision = await decide(entry);
|
|
1053
|
-
if (decision === "promote") {
|
|
1054
|
-
const promote = handlePromote(entry, ctx);
|
|
1055
|
-
outcomes.push({ entry, decision, promote });
|
|
1056
|
-
if (!promote.ok) {
|
|
1057
|
-
return { initial, outcomes, residual: { exitCode: 2, errorMessage: promote.message } };
|
|
1058
|
-
}
|
|
1059
|
-
continue;
|
|
1060
|
-
}
|
|
1061
|
-
if (decision === "overwrite") {
|
|
1062
|
-
const overwrite = await handleOverwrite(entry, ctx, deps.client);
|
|
1063
|
-
outcomes.push({ entry, decision, overwrite });
|
|
1064
|
-
if (!overwrite.ok && overwrite.reason === "infra") {
|
|
1065
|
-
return { initial, outcomes, residual: { exitCode: 2, errorMessage: overwrite.message } };
|
|
1066
|
-
}
|
|
1067
|
-
continue;
|
|
1068
|
-
}
|
|
1069
|
-
outcomes.push({ entry, decision: "skip" });
|
|
495
|
+
for (const entry of state.drift.systemObjectDrift) {
|
|
496
|
+
parts.push(`## \`${entry.objectName}\`${entry.sealed ? " (sealed)" : ""} \u2014 custom attributes`);
|
|
497
|
+
for (const a of entry.customAttributes)
|
|
498
|
+
parts.push(renderAttribute(entry.objectName, entry.sealed, a));
|
|
1070
499
|
}
|
|
1071
|
-
const
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
const options = [
|
|
1076
|
-
{
|
|
1077
|
-
value: "promote",
|
|
1078
|
-
label: "Promote",
|
|
1079
|
-
hint: "write the runtime config to .standards/promotions/"
|
|
1080
|
-
}
|
|
1081
|
-
];
|
|
1082
|
-
if (entry.state === "conflict") {
|
|
1083
|
-
options.push({
|
|
1084
|
-
value: "overwrite",
|
|
1085
|
-
label: "Overwrite",
|
|
1086
|
-
hint: "accept the code version, discard the runtime change"
|
|
1087
|
-
});
|
|
500
|
+
const divergedViews = state.views.filter(isViewDiverged);
|
|
501
|
+
if (divergedViews.length) {
|
|
502
|
+
parts.push("## Drifted views");
|
|
503
|
+
for (const v of divergedViews) parts.push(renderView(v));
|
|
1088
504
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
505
|
+
return `${parts.join("\n\n")}
|
|
506
|
+
`;
|
|
1091
507
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
return
|
|
508
|
+
|
|
509
|
+
// src/commands/pull.ts
|
|
510
|
+
async function runPullCommand(client) {
|
|
511
|
+
let state;
|
|
512
|
+
try {
|
|
513
|
+
state = await client.get("/schema/state");
|
|
514
|
+
} catch (error) {
|
|
515
|
+
return { exitCode: 2, errorMessage: messageOf(error) };
|
|
1100
516
|
}
|
|
1101
|
-
return
|
|
517
|
+
return { exitCode: 0, prompt: renderReconciliationPrompt(state) };
|
|
1102
518
|
}
|
|
1103
519
|
function registerPullCommand(program) {
|
|
1104
|
-
program.command("pull").description("
|
|
520
|
+
program.command("pull").description("Emit a self-contained reconciliation prompt for the current runtime drift").option("--json", "Emit the raw /schema/state JSON instead of the prompt").action(async (opts, cmd) => {
|
|
1105
521
|
let client;
|
|
1106
|
-
let schemaEntry;
|
|
1107
|
-
let standardsDir;
|
|
1108
522
|
try {
|
|
1109
523
|
client = getClientFromCommand(cmd);
|
|
1110
|
-
schemaEntry = readStandardsProjectConfig().schemaEntry;
|
|
1111
|
-
standardsDir = resolveStandardsDir();
|
|
1112
524
|
} catch (error) {
|
|
1113
|
-
console.error(
|
|
1114
|
-
process.exit(2);
|
|
1115
|
-
return;
|
|
1116
|
-
}
|
|
1117
|
-
let result;
|
|
1118
|
-
try {
|
|
1119
|
-
result = await runPullCommand({ client, schemaEntry, standardsDir }, promptDecision);
|
|
1120
|
-
} catch (error) {
|
|
1121
|
-
console.error(chalk7.red(`\u2717 ${messageOf(error)}`));
|
|
1122
|
-
process.exit(2);
|
|
1123
|
-
return;
|
|
1124
|
-
}
|
|
1125
|
-
if (result.initial.exitCode === 2) {
|
|
1126
|
-
console.error(chalk7.red(`\u2717 ${result.initial.errorMessage}`));
|
|
525
|
+
console.error(chalk5.red(`\u2717 ${messageOf(error)}`));
|
|
1127
526
|
process.exit(2);
|
|
1128
527
|
return;
|
|
1129
528
|
}
|
|
1130
|
-
if (
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
console.error(chalk7.red(`\u2717 ${outcome.promote.message}`));
|
|
1139
|
-
}
|
|
1140
|
-
} else if (outcome.decision === "overwrite") {
|
|
1141
|
-
if (outcome.overwrite.ok) {
|
|
1142
|
-
console.info(chalk7.green(`\u2713 Overwrote ${outcome.entry.key}`));
|
|
1143
|
-
} else {
|
|
1144
|
-
console.error(chalk7.red(`\u2717 ${outcome.overwrite.message}`));
|
|
1145
|
-
}
|
|
529
|
+
if (opts.json) {
|
|
530
|
+
try {
|
|
531
|
+
const state = await client.get("/schema/state");
|
|
532
|
+
console.info(JSON.stringify(state, null, 2));
|
|
533
|
+
process.exit(0);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
console.error(chalk5.red(`\u2717 ${messageOf(error)}`));
|
|
536
|
+
process.exit(2);
|
|
1146
537
|
}
|
|
538
|
+
return;
|
|
1147
539
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
540
|
+
const result = await runPullCommand(client);
|
|
541
|
+
if (result.exitCode === 2) {
|
|
542
|
+
console.error(chalk5.red(`\u2717 ${result.errorMessage}`));
|
|
1150
543
|
process.exit(2);
|
|
1151
544
|
return;
|
|
1152
545
|
}
|
|
1153
|
-
console.info(
|
|
1154
|
-
process.exit(
|
|
546
|
+
console.info(result.prompt);
|
|
547
|
+
process.exit(0);
|
|
1155
548
|
});
|
|
1156
549
|
}
|
|
1157
550
|
|
|
@@ -1162,6 +555,11 @@ function parseSortFlag(sort) {
|
|
|
1162
555
|
const [attribute, direction = "asc"] = sort.split(":");
|
|
1163
556
|
return [{ attribute, direction }];
|
|
1164
557
|
}
|
|
558
|
+
var COUNT_MODES = ["none", "estimated", "exact"];
|
|
559
|
+
function parseCountFlag(count) {
|
|
560
|
+
if (COUNT_MODES.includes(count)) return count;
|
|
561
|
+
throw new Error(`Invalid --count value "${count}". Expected one of: ${COUNT_MODES.join(", ")}.`);
|
|
562
|
+
}
|
|
1165
563
|
function buildQueryBody(opts) {
|
|
1166
564
|
const body = {};
|
|
1167
565
|
if (opts.limit) body.limit = Number(opts.limit);
|
|
@@ -1169,14 +567,16 @@ function buildQueryBody(opts) {
|
|
|
1169
567
|
if (opts.sort) body.sorts = parseSortFlag(opts.sort);
|
|
1170
568
|
if (opts.filter) body.filters = JSON.parse(opts.filter);
|
|
1171
569
|
if (opts.fields !== void 0) body.fields = parseCsvList(opts.fields);
|
|
570
|
+
if (opts.count) body.countMode = parseCountFlag(opts.count);
|
|
1172
571
|
return body;
|
|
1173
572
|
}
|
|
573
|
+
var COUNT_OPTION_DESCRIPTION = "total count mode (none, estimated, exact); omit for no total in page.total";
|
|
1174
574
|
function registerRecordsCommand(program) {
|
|
1175
575
|
const records = program.command("records").description("Manage records (CRUD + search)");
|
|
1176
576
|
records.command("list").description("List records for an object").argument("<object>", "object name (e.g. contacts)").option("--limit <n>", "max records to return").option("--offset <n>", "number of records to skip").option("--sort <sort>", 'sort rule as "attribute:direction"').option("--filter <json>", "filter state as JSON string").option(
|
|
1177
577
|
"--fields <names>",
|
|
1178
578
|
"comma-separated reference attributes to hydrate (empty for none, omit for all)"
|
|
1179
|
-
).action(async (objectName, opts, cmd) => {
|
|
579
|
+
).option("--count <mode>", COUNT_OPTION_DESCRIPTION).action(async (objectName, opts, cmd) => {
|
|
1180
580
|
const client = getClientFromCommand(cmd);
|
|
1181
581
|
const body = buildQueryBody(opts);
|
|
1182
582
|
const result = await client.post(`/records/${objectName}/list`, body);
|
|
@@ -1208,7 +608,7 @@ function registerRecordsCommand(program) {
|
|
|
1208
608
|
records.command("search").description("Full-text search records").argument("<object>", "object name").requiredOption("--query <q>", "search query string").option("--limit <n>", "max records to return").option("--offset <n>", "number of records to skip").option("--sort <sort>", 'sort rule as "attribute:direction"').option("--filter <json>", "filter state as JSON string").option(
|
|
1209
609
|
"--fields <names>",
|
|
1210
610
|
"comma-separated reference attributes to hydrate (empty for none, omit for all)"
|
|
1211
|
-
).action(async (objectName, opts, cmd) => {
|
|
611
|
+
).option("--count <mode>", COUNT_OPTION_DESCRIPTION).action(async (objectName, opts, cmd) => {
|
|
1212
612
|
const client = getClientFromCommand(cmd);
|
|
1213
613
|
const body = { q: opts.query, ...buildQueryBody(opts) };
|
|
1214
614
|
const result = await client.post(`/records/${objectName}/search`, body);
|
|
@@ -1235,20 +635,20 @@ function registerRecordsCommand(program) {
|
|
|
1235
635
|
// src/commands/root.ts
|
|
1236
636
|
import { stdin as input, stdout as output } from "process";
|
|
1237
637
|
import { createInterface } from "readline/promises";
|
|
1238
|
-
import
|
|
638
|
+
import chalk6 from "chalk";
|
|
1239
639
|
|
|
1240
640
|
// src/config.ts
|
|
1241
641
|
import { mkdir, readFile as readFile3, rm, writeFile } from "fs/promises";
|
|
1242
642
|
import { homedir } from "os";
|
|
1243
|
-
import { dirname
|
|
643
|
+
import { dirname, join } from "path";
|
|
1244
644
|
var DEFAULT_API_URL = "http://localhost:4100/v1";
|
|
1245
645
|
var ENV_PROFILE_NAME = "sandbox";
|
|
1246
646
|
function getDefaultApiUrl() {
|
|
1247
647
|
return DEFAULT_API_URL;
|
|
1248
648
|
}
|
|
1249
649
|
function getConfigPath() {
|
|
1250
|
-
const configDir = process.env.STANDARDS_CONFIG_DIR ??
|
|
1251
|
-
return
|
|
650
|
+
const configDir = process.env.STANDARDS_CONFIG_DIR ?? join(homedir(), ".standards");
|
|
651
|
+
return join(configDir, "config.json");
|
|
1252
652
|
}
|
|
1253
653
|
async function readConfig() {
|
|
1254
654
|
try {
|
|
@@ -1267,7 +667,7 @@ async function readConfig() {
|
|
|
1267
667
|
}
|
|
1268
668
|
async function writeConfig(config) {
|
|
1269
669
|
const path = getConfigPath();
|
|
1270
|
-
await mkdir(
|
|
670
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
1271
671
|
await writeFile(path, `${JSON.stringify(config, null, 2)}
|
|
1272
672
|
`, { mode: 384 });
|
|
1273
673
|
}
|
|
@@ -1375,7 +775,7 @@ function registerRootCommands(program) {
|
|
|
1375
775
|
await client.get("/api-keys");
|
|
1376
776
|
await upsertProfile({ name: opts.name, apiUrl: opts.url, apiKey });
|
|
1377
777
|
process.stdout.write(
|
|
1378
|
-
`${
|
|
778
|
+
`${chalk6.green("\u2713")} Standards instance "${opts.name}" saved and selected.
|
|
1379
779
|
`
|
|
1380
780
|
);
|
|
1381
781
|
process.stdout.write(` API URL: ${opts.url}
|
|
@@ -1387,7 +787,7 @@ function registerRootCommands(program) {
|
|
|
1387
787
|
});
|
|
1388
788
|
program.command("use").description("Select the active Standards instance").argument("<name>", "instance name").action(async (name) => {
|
|
1389
789
|
await setCurrentProfile(name);
|
|
1390
|
-
process.stdout.write(`${
|
|
790
|
+
process.stdout.write(`${chalk6.green("\u2713")} Standards instance "${name}" selected.
|
|
1391
791
|
`);
|
|
1392
792
|
});
|
|
1393
793
|
program.command("instances").description("List configured Standards instances").action(async (_opts, cmd) => {
|
|
@@ -1406,7 +806,7 @@ function registerRootCommands(program) {
|
|
|
1406
806
|
});
|
|
1407
807
|
program.command("logout").description("Remove a Standards instance from local CLI config").argument("[name]", "instance name, defaults to current").action(async (name) => {
|
|
1408
808
|
await removeProfile(name);
|
|
1409
|
-
process.stdout.write(`${
|
|
809
|
+
process.stdout.write(`${chalk6.green("\u2713")} Standards instance removed.
|
|
1410
810
|
`);
|
|
1411
811
|
});
|
|
1412
812
|
}
|
|
@@ -1427,7 +827,7 @@ function registerSchemaCommand(program) {
|
|
|
1427
827
|
}
|
|
1428
828
|
|
|
1429
829
|
// src/program.ts
|
|
1430
|
-
var PUBLIC_COMMANDS = /* @__PURE__ */ new Set(["login", "use", "instances", "current", "logout", "help"
|
|
830
|
+
var PUBLIC_COMMANDS = /* @__PURE__ */ new Set(["login", "use", "instances", "current", "logout", "help"]);
|
|
1431
831
|
function isPublicCommand(actionCommand) {
|
|
1432
832
|
if (PUBLIC_COMMANDS.has(actionCommand.name())) return true;
|
|
1433
833
|
return actionCommand.parent?.name() === "auth" && actionCommand.name() === "help";
|
|
@@ -1438,14 +838,11 @@ function createProgram() {
|
|
|
1438
838
|
registerRootCommands(program);
|
|
1439
839
|
registerRecordsCommand(program);
|
|
1440
840
|
registerSchemaCommand(program);
|
|
1441
|
-
registerDiffCommand(program);
|
|
1442
841
|
registerPullCommand(program);
|
|
1443
|
-
registerInitCommand(program);
|
|
1444
842
|
registerDocumentsCommand(program);
|
|
1445
843
|
registerFoldersCommand(program);
|
|
1446
844
|
registerKeysCommand(program);
|
|
1447
845
|
registerAuthCommand(program);
|
|
1448
|
-
registerAutofillCommand(program);
|
|
1449
846
|
registerBundlesCommand(program);
|
|
1450
847
|
registerConnectorsCommand(program);
|
|
1451
848
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
@@ -1461,7 +858,7 @@ function createProgram() {
|
|
|
1461
858
|
program.setOptionValue("tenant", raw.tenant);
|
|
1462
859
|
if (!(resolved.apiKey || isPublicCommand(actionCommand))) {
|
|
1463
860
|
console.error(
|
|
1464
|
-
|
|
861
|
+
chalk7.red(
|
|
1465
862
|
`\u2717 Error: No Standards instance configured. Run "standards login" or pass --api-key.`
|
|
1466
863
|
)
|
|
1467
864
|
);
|
|
@@ -1475,12 +872,12 @@ async function runProgram(argv = process.argv) {
|
|
|
1475
872
|
await program.parseAsync(argv).catch((error) => {
|
|
1476
873
|
if (error instanceof ApiClientError) {
|
|
1477
874
|
if (error.statusCode > 0) {
|
|
1478
|
-
console.error(
|
|
875
|
+
console.error(chalk7.red(`\u2717 Error (${error.statusCode}): ${error.message}`));
|
|
1479
876
|
} else {
|
|
1480
|
-
console.error(
|
|
877
|
+
console.error(chalk7.red(`\u2717 Error: ${error.message}`));
|
|
1481
878
|
}
|
|
1482
879
|
} else if (error instanceof Error) {
|
|
1483
|
-
console.error(
|
|
880
|
+
console.error(chalk7.red(`\u2717 Error: ${error.message}`));
|
|
1484
881
|
}
|
|
1485
882
|
process.exit(1);
|
|
1486
883
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stndrds/cli",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.264",
|
|
4
4
|
"description": "CLI tool to interact with Standards API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,12 +10,10 @@
|
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@clack/prompts": "^1.7.0",
|
|
14
13
|
"chalk": "^5.4.1",
|
|
15
14
|
"cli-table3": "^0.6.5",
|
|
16
15
|
"commander": "^13.1.0",
|
|
17
|
-
"
|
|
18
|
-
"@stndrds/schema": "1.0.0-alpha.263"
|
|
16
|
+
"@stndrds/schema": "1.0.0-alpha.264"
|
|
19
17
|
},
|
|
20
18
|
"devDependencies": {
|
|
21
19
|
"@types/node": "^25.6.0",
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
name: Standards schema/view drift
|
|
2
|
-
|
|
3
|
-
# Runs "standards diff" against the deployed (prod) Standards instance on every
|
|
4
|
-
# PR, so schema/view drift that would be silently overwritten or destroyed on
|
|
5
|
-
# the next deploy is caught at review time instead of after rollout.
|
|
6
|
-
on:
|
|
7
|
-
pull_request:
|
|
8
|
-
|
|
9
|
-
permissions:
|
|
10
|
-
contents: read
|
|
11
|
-
pull-requests: write
|
|
12
|
-
|
|
13
|
-
jobs:
|
|
14
|
-
standards-drift:
|
|
15
|
-
name: standards diff
|
|
16
|
-
runs-on: ubuntu-latest
|
|
17
|
-
steps:
|
|
18
|
-
- name: Checkout
|
|
19
|
-
uses: actions/checkout@v4
|
|
20
|
-
|
|
21
|
-
- name: Setup Node
|
|
22
|
-
uses: actions/setup-node@v4
|
|
23
|
-
with:
|
|
24
|
-
node-version: 22
|
|
25
|
-
|
|
26
|
-
- name: Enable corepack (pnpm)
|
|
27
|
-
run: corepack enable
|
|
28
|
-
|
|
29
|
-
- name: Install dependencies
|
|
30
|
-
run: pnpm install --frozen-lockfile
|
|
31
|
-
|
|
32
|
-
# `standards diff` reads its credentials the same way every other CLI
|
|
33
|
-
# command does (see packages/cli/src/config.ts:resolveCliConfig /
|
|
34
|
-
# getEnvProfile): STANDARDS_API_URL + STANDARDS_API_KEY. These are the
|
|
35
|
-
# exact env var names the client reads — do not rename without updating
|
|
36
|
-
# both the CLI and this workflow.
|
|
37
|
-
#
|
|
38
|
-
# `--format json` is captured for the PR comment step below; the human-
|
|
39
|
-
# readable table is still what a developer sees if they run the same
|
|
40
|
-
# command locally without --format. Stderr is captured separately
|
|
41
|
-
# (`standards diff` prints its exit-2 failure message via
|
|
42
|
-
# `console.error`, i.e. to stderr, never into the JSON stdout) so the
|
|
43
|
-
# comment step has something to show for an infra failure instead of an
|
|
44
|
-
# empty code block.
|
|
45
|
-
- name: Run standards diff
|
|
46
|
-
id: standards-diff
|
|
47
|
-
env:
|
|
48
|
-
STANDARDS_API_URL: ${{ secrets.STANDARDS_API_URL }}
|
|
49
|
-
STANDARDS_API_KEY: ${{ secrets.STANDARDS_API_KEY }}
|
|
50
|
-
run: |
|
|
51
|
-
set +e
|
|
52
|
-
pnpm exec standards diff --format json > standards-diff.json 2> standards-diff.err
|
|
53
|
-
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
|
|
54
|
-
cat standards-diff.json
|
|
55
|
-
cat standards-diff.err >&2
|
|
56
|
-
exit 0
|
|
57
|
-
|
|
58
|
-
# Fork PRs run with a read-only GITHUB_TOKEN (no `pull-requests: write`),
|
|
59
|
-
# so `github.rest.issues.createComment`/`updateComment` below would 403.
|
|
60
|
-
# Skip the comment (not the check itself — "Fail the job" below still
|
|
61
|
-
# runs and still gates the merge) for PRs from forks. See the maintainer
|
|
62
|
-
# note at the bottom of this file.
|
|
63
|
-
- name: Comment drift report on PR
|
|
64
|
-
if: github.event.pull_request.head.repo.full_name == github.repository
|
|
65
|
-
uses: actions/github-script@v7
|
|
66
|
-
with:
|
|
67
|
-
script: |
|
|
68
|
-
const fs = require("fs");
|
|
69
|
-
const exitCode = Number("${{ steps.standards-diff.outputs.exit_code }}");
|
|
70
|
-
const marker = "<!-- standards-drift-report -->";
|
|
71
|
-
|
|
72
|
-
const stdout = fs.existsSync("standards-diff.json") ? fs.readFileSync("standards-diff.json", "utf-8") : "";
|
|
73
|
-
const stderr = fs.existsSync("standards-diff.err") ? fs.readFileSync("standards-diff.err", "utf-8") : "";
|
|
74
|
-
|
|
75
|
-
let report = null;
|
|
76
|
-
if (stdout.trim().length > 0) {
|
|
77
|
-
try {
|
|
78
|
-
report = JSON.parse(stdout);
|
|
79
|
-
} catch {
|
|
80
|
-
// exit code 2 (infra failure) can leave non-JSON stdout (e.g.
|
|
81
|
-
// the process never got far enough to print a report) — the
|
|
82
|
-
// stderr capture above is what carries the actual message then.
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// `state !== "in-sync"` on its own would lump `code-changed`/
|
|
87
|
-
// `conflict-resolved` (informational — will simply apply, nothing
|
|
88
|
-
// blocking) in with `conflict`/`runtime-diverged` (blocking,
|
|
89
|
-
// needs a developer decision). Keep them in separate tables so
|
|
90
|
-
// the "needs a decision" table is true of every row it lists.
|
|
91
|
-
function renderTable(rows) {
|
|
92
|
-
if (rows.length === 0) return "_None._";
|
|
93
|
-
const header = "| View | State | Changed fields |\n| --- | --- | --- |";
|
|
94
|
-
const body = rows
|
|
95
|
-
.map((v) => `| \`${v.key}\` | ${v.state} | ${(v.changedFields ?? []).join(", ") || "—"} |`)
|
|
96
|
-
.join("\n");
|
|
97
|
-
return `${header}\n${body}`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function renderDecisionViewsTable(views) {
|
|
101
|
-
return renderTable(views.filter((v) => v.state === "conflict" || v.state === "runtime-diverged"));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function renderInformationalViewsTable(views) {
|
|
105
|
-
return renderTable(views.filter((v) => v.state === "code-changed" || v.state === "conflict-resolved"));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function renderSchemaTable(schema) {
|
|
109
|
-
const lines = [];
|
|
110
|
-
for (const obj of schema?.customObjects ?? []) {
|
|
111
|
-
lines.push(`| \`${obj.name}\` | custom object, not in code |`);
|
|
112
|
-
}
|
|
113
|
-
for (const entry of schema?.systemObjectDrift ?? []) {
|
|
114
|
-
const unexpected = entry.sealed
|
|
115
|
-
? entry.customAttributes.filter((a) => !a.tolerated)
|
|
116
|
-
: [];
|
|
117
|
-
if (entry.sealed && unexpected.length > 0) {
|
|
118
|
-
lines.push(
|
|
119
|
-
`| \`${entry.objectName}\` | ${unexpected.length} unexpected attribute(s) (sealed) |`
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
if (lines.length === 0) return "_No object/attribute drift._";
|
|
124
|
-
return `| Object | Note |\n| --- | --- |\n${lines.join("\n")}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
let body;
|
|
128
|
-
if (exitCode === 2) {
|
|
129
|
-
const details = stderr.trim().length > 0 ? stderr.trim() : stdout.trim().length > 0 ? stdout.trim() : "(no output captured)";
|
|
130
|
-
body = [
|
|
131
|
-
marker,
|
|
132
|
-
"## ⚠️ standards diff — could not run (exit code 2)",
|
|
133
|
-
"",
|
|
134
|
-
"This is an **infra/configuration failure**, not schema or view drift —",
|
|
135
|
-
"the check itself did not complete. Common causes: a missing or invalid",
|
|
136
|
-
"`STANDARDS_API_URL` / `STANDARDS_API_KEY` secret, an unreachable API, or a",
|
|
137
|
-
"schema entry file that fails to load in isolation.",
|
|
138
|
-
"",
|
|
139
|
-
"```",
|
|
140
|
-
details,
|
|
141
|
-
"```",
|
|
142
|
-
].join("\n");
|
|
143
|
-
} else if (exitCode === 0) {
|
|
144
|
-
body = [marker, "## ✅ standards diff — no blocking drift", "", "Next deploy is a no-op for schema/views."].join(
|
|
145
|
-
"\n"
|
|
146
|
-
);
|
|
147
|
-
} else {
|
|
148
|
-
body = [
|
|
149
|
-
marker,
|
|
150
|
-
"## 🚫 standards diff — unresolved drift (exit code 1)",
|
|
151
|
-
"",
|
|
152
|
-
"### Needs a decision",
|
|
153
|
-
"",
|
|
154
|
-
"These views WOULD have their runtime change overwritten or destroyed by",
|
|
155
|
-
"the next deploy. Run `standards pull` locally to resolve each one (promote",
|
|
156
|
-
"the runtime change into code, or explicitly overwrite it), then push again.",
|
|
157
|
-
"",
|
|
158
|
-
renderDecisionViewsTable(report?.views ?? []),
|
|
159
|
-
"",
|
|
160
|
-
"### Will apply automatically (informational, no action needed)",
|
|
161
|
-
"",
|
|
162
|
-
renderInformationalViewsTable(report?.views ?? []),
|
|
163
|
-
"",
|
|
164
|
-
"### Schema (objects/attributes)",
|
|
165
|
-
"",
|
|
166
|
-
renderSchemaTable(report?.schema ?? {}),
|
|
167
|
-
].join("\n");
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const { data: comments } = await github.rest.issues.listComments({
|
|
171
|
-
owner: context.repo.owner,
|
|
172
|
-
repo: context.repo.repo,
|
|
173
|
-
issue_number: context.issue.number,
|
|
174
|
-
});
|
|
175
|
-
const existing = comments.find((c) => c.body?.includes(marker));
|
|
176
|
-
|
|
177
|
-
if (existing) {
|
|
178
|
-
await github.rest.issues.updateComment({
|
|
179
|
-
owner: context.repo.owner,
|
|
180
|
-
repo: context.repo.repo,
|
|
181
|
-
comment_id: existing.id,
|
|
182
|
-
body,
|
|
183
|
-
});
|
|
184
|
-
} else {
|
|
185
|
-
await github.rest.issues.createComment({
|
|
186
|
-
owner: context.repo.owner,
|
|
187
|
-
repo: context.repo.repo,
|
|
188
|
-
issue_number: context.issue.number,
|
|
189
|
-
body,
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
- name: Fail the job on unresolved drift or infra failure
|
|
194
|
-
if: steps.standards-diff.outputs.exit_code != '0'
|
|
195
|
-
run: |
|
|
196
|
-
echo "standards diff exited with code ${{ steps.standards-diff.outputs.exit_code }}"
|
|
197
|
-
exit 1
|
|
198
|
-
|
|
199
|
-
# ---------------------------------------------------------------------------
|
|
200
|
-
# Notes for whoever maintains this workflow
|
|
201
|
-
# ---------------------------------------------------------------------------
|
|
202
|
-
#
|
|
203
|
-
# 1. Deploy-time re-check (close the merge -> deploy TOCTOU window):
|
|
204
|
-
# A PR can be green here and still go stale — a runtime user can edit a
|
|
205
|
-
# view in the time between "this PR merged" and "this commit actually
|
|
206
|
-
# deployed". Copy the "Run standards diff" step (same env vars, same
|
|
207
|
-
# command) into your deploy pipeline immediately BEFORE the rollout step,
|
|
208
|
-
# and treat a non-zero exit there as a deploy abort:
|
|
209
|
-
# - exit 1 -> abort the deploy, drift must be resolved first (the exact
|
|
210
|
-
# same resolution flow as above: `standards pull`, or hand this off to
|
|
211
|
-
# the `standards-pull` agent skill).
|
|
212
|
-
# - exit 2 -> abort the deploy, this is an infra failure (bad
|
|
213
|
-
# credentials, unreachable API), not a drift decision.
|
|
214
|
-
# Without this second check, this PR-time gate only protects the instant
|
|
215
|
-
# of merge, not the instant of deploy.
|
|
216
|
-
#
|
|
217
|
-
# 2. v1 assumes single-tenant consumer deployments: one STANDARDS_API_KEY
|
|
218
|
-
# maps to exactly one tenant. If your deployment serves multiple tenants
|
|
219
|
-
# per Standards instance, this workflow (and `standards diff`/`standards
|
|
220
|
-
# pull` generally) only checks the one tenant reachable with the configured
|
|
221
|
-
# key — repeat the job (or the deploy-time re-check) once per tenant.
|
|
222
|
-
#
|
|
223
|
-
# 3. Exit codes, spelled out (see packages/cli/src/commands/diff.ts):
|
|
224
|
-
# 0 = no blocking drift, next deploy is a no-op for schema/views.
|
|
225
|
-
# 1 = blocking drift found (an unresolved conflict, or unexpected sealed-
|
|
226
|
-
# object drift) — the next deploy WOULD destroy runtime changes.
|
|
227
|
-
# 2 = infra failure: network/auth error, missing/invalid
|
|
228
|
-
# STANDARDS_API_URL / STANDARDS_API_KEY, or the schema entry failed
|
|
229
|
-
# to load in isolation. This is NOT drift — do not treat it as "no
|
|
230
|
-
# drift" (it is not 0) and do not treat it as a conflict to resolve
|
|
231
|
-
# (editing schema code will not fix a missing secret).
|
|
232
|
-
#
|
|
233
|
-
# 4. Fork PRs: GitHub gives a `pull_request` run triggered from a fork a
|
|
234
|
-
# read-only GITHUB_TOKEN (no `pull-requests: write`), so the "Comment
|
|
235
|
-
# drift report on PR" step is guarded with
|
|
236
|
-
# `if: github.event.pull_request.head.repo.full_name == github.repository`
|
|
237
|
-
# and is simply skipped for fork PRs — it would otherwise fail with a 403
|
|
238
|
-
# trying to post the comment. The "Fail the job on unresolved drift or
|
|
239
|
-
# infra failure" step is NOT guarded — it doesn't call the GitHub API, so
|
|
240
|
-
# it still gates the merge for fork PRs; the developer just won't get the
|
|
241
|
-
# rendered table, only the job's own log output (which still contains the
|
|
242
|
-
# exit code and, on exit 2, the captured stderr).
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: standards-pull
|
|
3
|
-
description: Resolve Standards schema/view drift between the code-defined schema and the deployed runtime — walks each conflicting or runtime-diverged view with the developer, promotes runtime changes into builder code, and only stops once "standards diff" reports no drift. Use when "standards diff" exits 1, when a PR's standards-drift CI check fails, or when the developer asks to resolve/promote/reconcile schema or view drift.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# standards-pull — resolve schema/view drift
|
|
7
|
-
|
|
8
|
-
## What drift means here
|
|
9
|
-
|
|
10
|
-
Standards is schema-driven: the code-defined schema (`object()`/`view()` builders) is
|
|
11
|
-
the source of truth, and it gets synced into the runtime database on deploy. Between
|
|
12
|
-
deploys, runtime users can edit views (labels, tabs, field layout) through the product
|
|
13
|
-
UI. `standards diff` compares the two and classifies every view into one of:
|
|
14
|
-
|
|
15
|
-
- `code-changed` — only code changed since the last sync; the next deploy will simply
|
|
16
|
-
apply it. Nothing to do.
|
|
17
|
-
- `runtime-diverged` — a runtime user changed the view and code has NOT changed it;
|
|
18
|
-
the next deploy would silently overwrite their change. Needs a decision.
|
|
19
|
-
- `conflict` — BOTH code and the runtime changed the same view since the last sync.
|
|
20
|
-
The next deploy WOULD DESTROY the runtime change. Blocking (exit code 1).
|
|
21
|
-
- `conflict-resolved` — a conflict that a developer already acknowledged via
|
|
22
|
-
`standards pull`'s "overwrite" decision; code will apply cleanly.
|
|
23
|
-
- `in-sync` — no drift, no line printed.
|
|
24
|
-
|
|
25
|
-
Exit codes (`standards diff` / `standards pull`): `0` = nothing blocking, `1` = at
|
|
26
|
-
least one `conflict` or sealed-object drift is unresolved, `2` = infra failure
|
|
27
|
-
(network error, the schema entry failed to load, misconfigured/missing credentials).
|
|
28
|
-
**Exit code 2 is not drift** — it means the check itself could not run (e.g. a CI job
|
|
29
|
-
with a missing `STANDARDS_API_KEY` secret). Never treat a `2` as "no drift" or as a
|
|
30
|
-
conflict to resolve; fix the underlying infra/config problem and re-run.
|
|
31
|
-
|
|
32
|
-
## Your job, precisely
|
|
33
|
-
|
|
34
|
-
You are invoked either because a developer asked you to resolve drift, or because a
|
|
35
|
-
CI check (`standards-drift.yml`) is failing on a PR. In both cases:
|
|
36
|
-
|
|
37
|
-
1. Run `standards diff --format json` and parse the JSON report. This is the ONLY
|
|
38
|
-
source of truth for what has drifted — never guess from reading builder files
|
|
39
|
-
alone, and never rely on a stale report from earlier in the conversation.
|
|
40
|
-
2. If the exit code is `2`, STOP and report the infra error message to the developer
|
|
41
|
-
verbatim — this is not something you can resolve by editing schema code (missing
|
|
42
|
-
`STANDARDS_API_KEY`/`STANDARDS_API_URL`, unreachable API, a schema entry that fails
|
|
43
|
-
to load in isolation). Fix what's actually broken (e.g. a bad import in the entry
|
|
44
|
-
file) and re-run `standards diff` before doing anything else.
|
|
45
|
-
3. If the exit code is `0`, there is nothing to do — say so and stop.
|
|
46
|
-
4. Otherwise, walk every item in the report **with the developer** — do not
|
|
47
|
-
silently decide on their behalf for anything destructive:
|
|
48
|
-
- `conflict` and `runtime-diverged` views are what need a decision. For each one,
|
|
49
|
-
tell the developer what changed on each side (the report's `changedFields`) and
|
|
50
|
-
ask: **promote** the runtime change into code, or (for `conflict` only)
|
|
51
|
-
**overwrite** it by running `standards pull` and choosing "Overwrite" for that
|
|
52
|
-
view (accepts the code version, discards the runtime change — only do this with
|
|
53
|
-
explicit developer confirmation, it is destructive to the runtime edit).
|
|
54
|
-
- `customObjects` / unexpected `systemObjectDrift` attributes: tell the developer
|
|
55
|
-
these exist only in the runtime, not in code, and ask whether to promote them
|
|
56
|
-
into the builder (add the attribute/object in code) or leave them as runtime-only
|
|
57
|
-
extensions.
|
|
58
|
-
5. To **promote**: run `standards pull` and choose "Promote" for the relevant view.
|
|
59
|
-
This writes `.standards/promotions/<object>-<name>-<type>.md`, containing:
|
|
60
|
-
- the view's current **runtime config** (the JSON to fold into the builder call —
|
|
61
|
-
tabs, fields, label, icon, etc.)
|
|
62
|
-
- a **builder file hint**: the file path and line of the existing
|
|
63
|
-
`listView(...)`/`detailView(...)` call for that view, when one is found
|
|
64
|
-
Read that file, then edit the builder call in the hinted file so the fluent
|
|
65
|
-
builder chain (`.tab(...)`, `.form(...)`, `.label(...)`, etc.) produces the same
|
|
66
|
-
shape as the runtime config in the promotion file. This is a translation task —
|
|
67
|
-
the promotion file gives you the target *data*, you write the target *code*.
|
|
68
|
-
6. **Loop `standards diff` until it exits 0.** After every edit (a promotion applied,
|
|
69
|
-
an overwrite decided, a builder file changed), re-run `standards diff --format
|
|
70
|
-
json` and re-parse it. Do not stop after one pass over the report — new drift can
|
|
71
|
-
surface (e.g. an edit that doesn't fully match the promoted config still shows as
|
|
72
|
-
`runtime-diverged`), and the loop is the only thing that proves you actually closed
|
|
73
|
-
every gap. **Never declare success without a green (`exitCode: 0`) `standards
|
|
74
|
-
diff` run.** A clean-looking diff of your own code edits is not sufficient — the
|
|
75
|
-
deterministic tool is what closes the loop, not your judgment about whether the
|
|
76
|
-
edits "look right".
|
|
77
|
-
|
|
78
|
-
## Guardrails
|
|
79
|
-
|
|
80
|
-
- Never run `standards pull`'s "Overwrite" decision without explicit developer
|
|
81
|
-
confirmation for that specific view — it discards real runtime user edits.
|
|
82
|
-
- Never hand-edit `.standards/config.json` or `.standards/promotions/*.md` — those
|
|
83
|
-
are written by the CLI (`standards init` / `standards pull`); editing them by hand
|
|
84
|
-
does not change what the runtime actually has and will desync your mental model
|
|
85
|
-
from `standards diff`'s next run.
|
|
86
|
-
- If a promotion file's builder hint says "No `listView`/`detailView` call found",
|
|
87
|
-
search the schema entry's directory yourself for where that object's other views
|
|
88
|
-
are declared — the view may be newly created at runtime and have no code
|
|
89
|
-
counterpart yet; promoting it means adding a NEW builder call, not editing one.
|
|
90
|
-
- If `standards diff` keeps reporting the same drift after an edit you believe is
|
|
91
|
-
correct, re-read the promotion file's runtime config carefully — a mismatched field
|
|
92
|
-
name, tab id, or attribute list is the most common cause, not a tooling bug.
|
|
93
|
-
|
|
94
|
-
## Related: deploy-time re-check
|
|
95
|
-
|
|
96
|
-
The merge of a PR and the actual deploy of that code are two different moments in
|
|
97
|
-
time — a runtime user can edit a view in the window between them. Projects that wired
|
|
98
|
-
`standards init --ci` should also run `standards diff` as a gate immediately before
|
|
99
|
-
the deploy step in their deploy pipeline (exit code `1` aborts the rollout), so this
|
|
100
|
-
skill's job — reconciling runtime drift — never gets skipped by that gap. If asked to
|
|
101
|
-
set that up, see the comment block at the end of `.github/workflows/standards-drift.yml`.
|