@stndrds/cli 1.0.0-alpha.261 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/program.ts
4
- import chalk9 from "chalk";
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 chalk6 from "chalk";
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(`${chalk6.green("\u2713")} API key ${id} revoked.
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 { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync, writeFileSync as writeFileSync2 } from "fs";
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
- \`\`\`ts
1024
- ${lines[lineIndex]?.trim()}
1025
- \`\`\``;
1026
- }
1027
- }
1028
- return `No \`${builderCall}("${name}", ...)\` call found under ${searchDir} \u2014 search manually for the "${object}" object's view builders.`;
1029
- }
1030
- function entriesNeedingDecisionOf(result) {
1031
- if (result.exitCode === 2) return [];
1032
- const conflicts = result.report.views.filter((view) => view.state === "conflict");
1033
- const diverged = result.report.views.filter((view) => view.state === "runtime-diverged");
1034
- return [...conflicts, ...diverged];
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
- async function runPullCommand(deps, decide) {
1037
- const initial = await runDiffCommand(deps);
1038
- if (initial.exitCode === 2) {
1039
- return { initial, outcomes: [], residual: initial };
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 entriesNeedingDecision = entriesNeedingDecisionOf(initial);
1042
- const viewStateByKey = new Map(
1043
- initial.state.views.map((entry) => [viewKey(entry.object, entry.name, entry.type), entry])
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 residual = await runDiffCommand(deps);
1072
- return { initial, outcomes, residual };
1073
- }
1074
- function decisionOptionsFor(entry) {
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
- options.push({ value: "skip", label: "Skip", hint: "leave this view as-is for now" });
1090
- return options;
505
+ return `${parts.join("\n\n")}
506
+ `;
1091
507
  }
1092
- async function promptDecision(entry) {
1093
- const label = entry.state === "conflict" ? "CONFLICT" : "runtime divergence";
1094
- const answer = await prompts.select({
1095
- message: `${entry.key} \u2014 ${label} (${entry.changedFields.join(", ")})`,
1096
- options: decisionOptionsFor(entry)
1097
- });
1098
- if (prompts.isCancel(answer)) {
1099
- return "skip";
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 answer;
517
+ return { exitCode: 0, prompt: renderReconciliationPrompt(state) };
1102
518
  }
1103
519
  function registerPullCommand(program) {
1104
- program.command("pull").description("Interactively resolve view drift: promote runtime changes or overwrite them").action(async (_opts, cmd) => {
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(chalk7.red(`\u2717 ${messageOf(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 (result.outcomes.length === 0) {
1131
- console.info(chalk7.dim("No conflicts or runtime-diverged views need a decision."));
1132
- }
1133
- for (const outcome of result.outcomes) {
1134
- if (outcome.decision === "promote") {
1135
- if (outcome.promote.ok) {
1136
- console.info(chalk7.green(`\u2713 Wrote ${outcome.promote.filePath}`));
1137
- } else {
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
- if (result.residual.exitCode === 2) {
1149
- console.error(chalk7.red(`\u2717 ${result.residual.errorMessage}`));
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(formatDiffReport(result.residual.report, resolveEffectiveFormat(cmd)));
1154
- process.exit(result.residual.exitCode);
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 chalk8 from "chalk";
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 as dirname5, join as join5 } from "path";
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 ?? join5(homedir(), ".standards");
1251
- return join5(configDir, "config.json");
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(dirname5(path), { recursive: true, mode: 448 });
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
- `${chalk8.green("\u2713")} Standards instance "${opts.name}" saved and selected.
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(`${chalk8.green("\u2713")} Standards instance "${name}" selected.
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(`${chalk8.green("\u2713")} Standards instance removed.
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", "init"]);
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
- chalk9.red(
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(chalk9.red(`\u2717 Error (${error.statusCode}): ${error.message}`));
875
+ console.error(chalk7.red(`\u2717 Error (${error.statusCode}): ${error.message}`));
1479
876
  } else {
1480
- console.error(chalk9.red(`\u2717 Error: ${error.message}`));
877
+ console.error(chalk7.red(`\u2717 Error: ${error.message}`));
1481
878
  }
1482
879
  } else if (error instanceof Error) {
1483
- console.error(chalk9.red(`\u2717 Error: ${error.message}`));
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.261",
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
- "esbuild": "^0.25.0",
18
- "@stndrds/schema": "1.0.0-alpha.261"
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`.