@intutic/cli 2.4.0 → 2.5.1
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/README.md +27 -16
- package/dist/cli.js +56 -12
- package/dist/cli.js.map +1 -1
- package/dist/commands/attenuate.d.ts +9 -8
- package/dist/commands/attenuate.d.ts.map +1 -1
- package/dist/commands/attenuate.js +18 -11
- package/dist/commands/attenuate.js.map +1 -1
- package/dist/commands/budget.d.ts.map +1 -1
- package/dist/commands/budget.js +3 -6
- package/dist/commands/budget.js.map +1 -1
- package/dist/commands/chaos.d.ts +40 -0
- package/dist/commands/chaos.d.ts.map +1 -0
- package/dist/commands/chaos.js +253 -0
- package/dist/commands/chaos.js.map +1 -0
- package/dist/commands/connect.d.ts +7 -0
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +79 -31
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/disconnect.d.ts +11 -1
- package/dist/commands/disconnect.d.ts.map +1 -1
- package/dist/commands/disconnect.js +18 -3
- package/dist/commands/disconnect.js.map +1 -1
- package/dist/commands/doctor.d.ts +7 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +21 -17
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/enforce.d.ts +14 -1
- package/dist/commands/enforce.d.ts.map +1 -1
- package/dist/commands/enforce.js +21 -3
- package/dist/commands/enforce.js.map +1 -1
- package/dist/commands/exec.d.ts +29 -1
- package/dist/commands/exec.d.ts.map +1 -1
- package/dist/commands/exec.js +72 -35
- package/dist/commands/exec.js.map +1 -1
- package/dist/commands/gateway.d.ts.map +1 -1
- package/dist/commands/gateway.js +3 -3
- package/dist/commands/gateway.js.map +1 -1
- package/dist/commands/guardrails.d.ts.map +1 -1
- package/dist/commands/guardrails.js +10 -8
- package/dist/commands/guardrails.js.map +1 -1
- package/dist/commands/integrity.d.ts.map +1 -1
- package/dist/commands/integrity.js +4 -1
- package/dist/commands/integrity.js.map +1 -1
- package/dist/commands/judge.d.ts.map +1 -1
- package/dist/commands/judge.js +6 -9
- package/dist/commands/judge.js.map +1 -1
- package/dist/commands/login.js +4 -4
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/policy.d.ts +16 -0
- package/dist/commands/policy.d.ts.map +1 -1
- package/dist/commands/policy.js +90 -8
- package/dist/commands/policy.js.map +1 -1
- package/dist/commands/siem.d.ts.map +1 -1
- package/dist/commands/siem.js.map +1 -1
- package/dist/commands/skill.d.ts +52 -1
- package/dist/commands/skill.d.ts.map +1 -1
- package/dist/commands/skill.js +147 -40
- package/dist/commands/skill.js.map +1 -1
- package/dist/commands/sops.d.ts +61 -50
- package/dist/commands/sops.d.ts.map +1 -1
- package/dist/commands/sops.js +425 -166
- package/dist/commands/sops.js.map +1 -1
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +2 -1
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +2 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/traces.d.ts.map +1 -1
- package/dist/commands/traces.js +4 -3
- package/dist/commands/traces.js.map +1 -1
- package/dist/config/paths.d.ts +15 -9
- package/dist/config/paths.d.ts.map +1 -1
- package/dist/config/paths.js +24 -10
- package/dist/config/paths.js.map +1 -1
- package/dist/harness/ag2.d.ts +4 -2
- package/dist/harness/ag2.d.ts.map +1 -1
- package/dist/harness/ag2.js +4 -2
- package/dist/harness/ag2.js.map +1 -1
- package/dist/harness/autogen.d.ts +10 -11
- package/dist/harness/autogen.d.ts.map +1 -1
- package/dist/harness/autogen.js +14 -15
- package/dist/harness/autogen.js.map +1 -1
- package/dist/harness/detector.d.ts.map +1 -1
- package/dist/harness/detector.js +5 -2
- package/dist/harness/detector.js.map +1 -1
- package/dist/harness/openhands.d.ts.map +1 -1
- package/dist/harness/openhands.js +3 -4
- package/dist/harness/openhands.js.map +1 -1
- package/dist/harness/windsurf.d.ts.map +1 -1
- package/dist/harness/windsurf.js +4 -2
- package/dist/harness/windsurf.js.map +1 -1
- package/dist/lib/api.d.ts +7 -3
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/caTrust.d.ts +43 -1
- package/dist/lib/caTrust.d.ts.map +1 -1
- package/dist/lib/caTrust.js +109 -9
- package/dist/lib/caTrust.js.map +1 -1
- package/dist/lib/chaos/experiment.d.ts +18 -0
- package/dist/lib/chaos/experiment.d.ts.map +1 -0
- package/dist/lib/chaos/experiment.js +40 -0
- package/dist/lib/chaos/experiment.js.map +1 -0
- package/dist/lib/chaos/probes.d.ts +97 -0
- package/dist/lib/chaos/probes.d.ts.map +1 -0
- package/dist/lib/chaos/probes.js +191 -0
- package/dist/lib/chaos/probes.js.map +1 -0
- package/dist/lib/chaos/runner.d.ts +131 -0
- package/dist/lib/chaos/runner.d.ts.map +1 -0
- package/dist/lib/chaos/runner.js +340 -0
- package/dist/lib/chaos/runner.js.map +1 -0
- package/dist/lib/chaos/workspace.d.ts +70 -0
- package/dist/lib/chaos/workspace.d.ts.map +1 -0
- package/dist/lib/chaos/workspace.js +216 -0
- package/dist/lib/chaos/workspace.js.map +1 -0
- package/dist/lib/ciscoScanner.d.ts +9 -4
- package/dist/lib/ciscoScanner.d.ts.map +1 -1
- package/dist/lib/ciscoScanner.js +42 -8
- package/dist/lib/ciscoScanner.js.map +1 -1
- package/dist/lib/decisionsLogRefresh.d.ts.map +1 -1
- package/dist/lib/decisionsLogRefresh.js +6 -2
- package/dist/lib/decisionsLogRefresh.js.map +1 -1
- package/dist/lib/enforcementState.d.ts +7 -0
- package/dist/lib/enforcementState.d.ts.map +1 -1
- package/dist/lib/enforcementState.js +9 -0
- package/dist/lib/enforcementState.js.map +1 -1
- package/dist/lib/gitHooks.d.ts.map +1 -1
- package/dist/lib/gitHooks.js +6 -4
- package/dist/lib/gitHooks.js.map +1 -1
- package/dist/lib/mdmManifest.d.ts.map +1 -1
- package/dist/lib/mdmManifest.js +33 -3
- package/dist/lib/mdmManifest.js.map +1 -1
- package/dist/lib/proxyBinary.d.ts +11 -0
- package/dist/lib/proxyBinary.d.ts.map +1 -1
- package/dist/lib/proxyBinary.js +16 -0
- package/dist/lib/proxyBinary.js.map +1 -1
- package/dist/lib/sandbox/firecracker.d.ts.map +1 -1
- package/dist/lib/sandbox/firecracker.js +4 -1
- package/dist/lib/sandbox/firecracker.js.map +1 -1
- package/dist/lib/sandbox/image.d.ts +13 -4
- package/dist/lib/sandbox/image.d.ts.map +1 -1
- package/dist/lib/sandbox/image.js +25 -8
- package/dist/lib/sandbox/image.js.map +1 -1
- package/dist/lib/sandbox/oci.d.ts.map +1 -1
- package/dist/lib/sandbox/oci.js +18 -3
- package/dist/lib/sandbox/oci.js.map +1 -1
- package/dist/lib/sandbox/types.d.ts +6 -0
- package/dist/lib/sandbox/types.d.ts.map +1 -1
- package/dist/lib/sopFrontMatter.d.ts +35 -0
- package/dist/lib/sopFrontMatter.d.ts.map +1 -1
- package/dist/lib/sopFrontMatter.js +60 -0
- package/dist/lib/sopFrontMatter.js.map +1 -1
- package/package.json +6 -5
- package/resources/chaos/credential-revoked.yaml +28 -0
- package/resources/chaos/poisoned-readme.yaml +29 -0
- package/resources/chaos/registry-down.yaml +25 -0
- package/resources/sandbox/Dockerfile.node +19 -0
- package/resources/sandbox/firecracker-launch.sh +8 -7
package/dist/commands/sops.js
CHANGED
|
@@ -1,158 +1,390 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
4
|
import { log } from '../lib/logger.js';
|
|
5
5
|
import { NOT_AUTHENTICATED } from '../lib/authMessages.js';
|
|
6
6
|
import { loadCredentials, loadConfig } from '../config/store.js';
|
|
7
7
|
import { resolveControlPlaneUrl } from '../config/paths.js';
|
|
8
8
|
import { createApiClient } from '../lib/api.js';
|
|
9
|
-
import { parseSopFile, titleFromFileName, renderSopFile, slugifyTitle } from '../lib/sopFrontMatter.js';
|
|
9
|
+
import { parseSopFile, titleFromFileName, renderSopFile, slugifyTitle, setFrontMatter, withSyncRecord, } from '../lib/sopFrontMatter.js';
|
|
10
10
|
async function getClient(dev) {
|
|
11
11
|
const creds = await loadCredentials();
|
|
12
12
|
if (!creds) {
|
|
13
13
|
log.error(NOT_AUTHENTICATED);
|
|
14
14
|
process.exit(1);
|
|
15
15
|
}
|
|
16
|
-
const
|
|
17
|
-
const devMode = dev || config?.devMode || process.env.INTUTIC_DEV === '1';
|
|
18
|
-
const controlPlaneUrl = resolveControlPlaneUrl(devMode);
|
|
16
|
+
const controlPlaneUrl = resolveControlPlaneUrl(dev);
|
|
19
17
|
return createApiClient(controlPlaneUrl, creds.apiKey);
|
|
20
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* What `sops push <name>` reads: the folder `.intutic/sops/<name>/` when
|
|
21
|
+
* there is one, else the single file `.intutic/sops/<name>.md` (or `<name>`
|
|
22
|
+
* given with its `.md`). The file form is the layout `sops pull` writes and
|
|
23
|
+
* the proxy enforces, so a pulled, edited SOP pushes back by its file name.
|
|
24
|
+
*/
|
|
25
|
+
export async function resolvePushTarget(sopsRoot, name) {
|
|
26
|
+
const folder = join(sopsRoot, name);
|
|
27
|
+
const isDir = await fs.stat(folder).then((st) => st.isDirectory(), () => false);
|
|
28
|
+
if (isDir) {
|
|
29
|
+
let files;
|
|
30
|
+
try {
|
|
31
|
+
files = (await fs.readdir(folder)).filter((e) => e.endsWith('.md')).sort();
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
return { error: `Error reading SOP files: ${err instanceof Error ? err.message : String(err)}` };
|
|
35
|
+
}
|
|
36
|
+
if (files.length === 0)
|
|
37
|
+
return { error: `No markdown (.md) files found in local SOP folder: ${folder}` };
|
|
38
|
+
return { dir: folder, files };
|
|
39
|
+
}
|
|
40
|
+
const fileName = name.endsWith('.md') ? name : `${name}.md`;
|
|
41
|
+
const file = join(sopsRoot, fileName);
|
|
42
|
+
const isFile = await fs.stat(file).then((st) => st.isFile(), () => false);
|
|
43
|
+
if (isFile)
|
|
44
|
+
return { dir: dirname(file), files: [basename(file)] };
|
|
45
|
+
return { error: `No SOP folder ${folder}/ or file ${file} found.` };
|
|
46
|
+
}
|
|
21
47
|
/**
|
|
22
48
|
* `intutic sops push <name>` — one control-plane SOP per file under
|
|
23
|
-
* `.intutic/sops/<name>/*.md
|
|
24
|
-
* `title:`/`risk_tier:`/`version:`
|
|
25
|
-
*
|
|
49
|
+
* `.intutic/sops/<name>/*.md` (or the single file `.intutic/sops/<name>.md`),
|
|
50
|
+
* each carrying that file's own declared `title:`/`risk_tier:`/`version:`
|
|
51
|
+
* front matter.
|
|
26
52
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
53
|
+
* A file that records the SOP it was synced with (`intutic_id:`, written by
|
|
54
|
+
* `sops pull` and by a previous push) updates that SOP in place through
|
|
55
|
+
* `PUT /api/v1/sops/:id`, the same edit path as the dashboard: a DRAFT is
|
|
56
|
+
* edited in place, and an approved SOP gets a new DRAFT version while the
|
|
57
|
+
* approved one keeps enforcing until the draft is promoted. The recorded
|
|
58
|
+
* `intutic_revision:` goes along as `version_counter`, so an edit made on the
|
|
59
|
+
* server since the file was synced is refused (409) instead of overwritten;
|
|
60
|
+
* `--force` sends the server's current counter instead. A file with no record
|
|
61
|
+
* creates a new SOP. Either way, a successful push writes the SOP's id and
|
|
62
|
+
* counter back into the file's front matter (a forked DRAFT's id, when the
|
|
63
|
+
* edit forked one), keeping every other line of the file.
|
|
34
64
|
*
|
|
35
|
-
* Front matter is stripped before the body is sent as `
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* Declarative enforcement keys (`deny_tools:`, `requires_before:`, ...) have
|
|
40
|
-
* no `sop_registry` column, so they are lost from the DB-plane row exactly
|
|
41
|
-
* as they always were — this fixes what already had somewhere to go
|
|
42
|
-
* (title, risk tier, version), not what does not.
|
|
65
|
+
* Front matter is stripped before the body is sent as `markdown_content` —
|
|
66
|
+
* the judge reads that field verbatim. Declarative enforcement keys
|
|
67
|
+
* (`deny_tools:`, `requires_before:`, ...) have no `sop_registry` column, so
|
|
68
|
+
* they stay file-plane only.
|
|
43
69
|
*/
|
|
44
70
|
export async function runSopsPush(name, opts) {
|
|
45
71
|
log.header(`Intutic — Push SOP: ${name}`);
|
|
46
72
|
const config = loadConfig();
|
|
47
73
|
const workspaceRoot = config?.workspaceRoot ?? process.cwd();
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
process.exit(1);
|
|
54
|
-
}
|
|
74
|
+
const sopsRoot = join(workspaceRoot, '.intutic', 'sops');
|
|
75
|
+
const target = await resolvePushTarget(sopsRoot, name);
|
|
76
|
+
if ('error' in target) {
|
|
77
|
+
log.error(target.error);
|
|
78
|
+
process.exit(1);
|
|
55
79
|
}
|
|
56
|
-
|
|
57
|
-
|
|
80
|
+
const { dir: sopsDir, files: mdFiles } = target;
|
|
81
|
+
const client = await getClient(opts.dev);
|
|
82
|
+
const ctx = { client, opts, successorsOf: null };
|
|
83
|
+
const counts = { created: 0, updated: 0, forked: 0, 'up-to-date': 0, skipped: 0, failed: 0 };
|
|
84
|
+
for (const file of mdFiles) {
|
|
85
|
+
const outcome = opts.org
|
|
86
|
+
? await pushOrgFile(client, sopsDir, file)
|
|
87
|
+
: await pushFile(ctx, join(sopsDir, file), file);
|
|
88
|
+
counts[outcome] += 1;
|
|
89
|
+
}
|
|
90
|
+
log.info(`${mdFiles.length} file(s) from ${sopsDir}: ${counts.created} created, ${counts.updated} updated, ` +
|
|
91
|
+
`${counts.forked} new DRAFT version(s), ${counts['up-to-date']} up to date, ${counts.failed} failed` +
|
|
92
|
+
(counts.skipped > 0 ? `, ${counts.skipped} skipped` : '') +
|
|
93
|
+
'.');
|
|
94
|
+
if (counts.failed > 0)
|
|
58
95
|
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const enc = encodeURIComponent;
|
|
98
|
+
function errMessage(err) {
|
|
99
|
+
return err instanceof Error ? err.message : String(err);
|
|
100
|
+
}
|
|
101
|
+
/** `--org` pushes a mandatory org-wide floor: create-only, as before. */
|
|
102
|
+
async function pushOrgFile(client, sopsDir, file) {
|
|
103
|
+
const parsed = parseSopFile(await fs.readFile(join(sopsDir, file), 'utf-8'));
|
|
104
|
+
if (!parsed.body) {
|
|
105
|
+
log.warn(`Skipping ${file}: empty after stripping front matter.`);
|
|
106
|
+
return 'skipped';
|
|
59
107
|
}
|
|
60
|
-
|
|
108
|
+
const title = parsed.title ?? titleFromFileName(file);
|
|
109
|
+
// The server derives orgId from the caller's own workspace; this CLI never
|
|
110
|
+
// resolves or supplies one.
|
|
61
111
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
112
|
+
const res = await client.post('/api/v1/workspace/org-sops', {
|
|
113
|
+
title,
|
|
114
|
+
markdown_content: parsed.body,
|
|
115
|
+
...(parsed.riskTier ? { risk_tier: parsed.riskTier } : {}),
|
|
116
|
+
});
|
|
117
|
+
if (res && res.orgSopId) {
|
|
118
|
+
log.info(`${file} → org-wide floor "${title}"`);
|
|
119
|
+
log.field('Org SOP ID', res.orgSopId);
|
|
120
|
+
return 'created';
|
|
66
121
|
}
|
|
122
|
+
log.error(`${file}: failed to create org SOP on the control plane.`);
|
|
67
123
|
}
|
|
68
124
|
catch (err) {
|
|
69
|
-
log.error(
|
|
70
|
-
process.exit(1);
|
|
125
|
+
log.error(`${file}: failed to push org SOP: ${errMessage(err)}`);
|
|
71
126
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
127
|
+
return 'failed';
|
|
128
|
+
}
|
|
129
|
+
async function pushFile(ctx, filePath, file) {
|
|
130
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
131
|
+
const parsed = parseSopFile(raw);
|
|
132
|
+
if (!parsed.body) {
|
|
133
|
+
log.warn(`Skipping ${file}: empty after stripping front matter.`);
|
|
134
|
+
return 'skipped';
|
|
135
|
+
}
|
|
136
|
+
const title = parsed.title ?? titleFromFileName(file);
|
|
137
|
+
if (parsed.intuticId) {
|
|
138
|
+
const id = parsed.intuticId;
|
|
139
|
+
let current;
|
|
140
|
+
try {
|
|
141
|
+
current = await ctx.client.getWithStatus(`/api/v1/sops/${enc(id)}`);
|
|
80
142
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// workspace; this CLI never resolves or supplies one.
|
|
85
|
-
if (opts.org) {
|
|
86
|
-
try {
|
|
87
|
-
const res = await client.post('/api/v1/workspace/org-sops', {
|
|
88
|
-
title,
|
|
89
|
-
markdown_content: parsed.body,
|
|
90
|
-
...(parsed.riskTier ? { risk_tier: parsed.riskTier } : {}),
|
|
91
|
-
});
|
|
92
|
-
if (res && res.orgSopId) {
|
|
93
|
-
log.info(`${file} → org-wide floor "${title}"`);
|
|
94
|
-
log.field('Org SOP ID', res.orgSopId);
|
|
95
|
-
}
|
|
96
|
-
else {
|
|
97
|
-
log.error(`${file}: failed to create org SOP on the control plane.`);
|
|
98
|
-
failures += 1;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
catch (err) {
|
|
102
|
-
log.error(`${file}: failed to push org SOP: ${err instanceof Error ? err.message : String(err)}`);
|
|
103
|
-
failures += 1;
|
|
104
|
-
}
|
|
105
|
-
continue;
|
|
143
|
+
catch (err) {
|
|
144
|
+
log.error(`${file}: could not read SOP ${id}: ${errMessage(err)}`);
|
|
145
|
+
return 'failed';
|
|
106
146
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// default (MEDIUM) apply rather than this CLI silently choosing
|
|
113
|
-
// LOW for every SOP that never mentioned one.
|
|
114
|
-
risk_tier: parsed.riskTier ?? 'MEDIUM',
|
|
115
|
-
// No front-matter equivalent exists for complexity — nothing in
|
|
116
|
-
// packages/proxy/src/sops.rs's front matter declares it.
|
|
117
|
-
complexity_tier: 'TIER_0',
|
|
118
|
-
...(parsed.version ? { version: parsed.version } : {}),
|
|
119
|
-
});
|
|
120
|
-
if (res && res.sopId) {
|
|
121
|
-
log.info(`${file} → workspace SOP "${title}"`);
|
|
122
|
-
log.field('SOP ID', res.sopId);
|
|
123
|
-
}
|
|
124
|
-
else {
|
|
125
|
-
log.error(`${file}: failed to create SOP on the control plane.`);
|
|
126
|
-
failures += 1;
|
|
147
|
+
if (current.status === 404) {
|
|
148
|
+
if (!ctx.opts.create) {
|
|
149
|
+
log.error(`${file}: SOP ${id} (its intutic_id) is no longer on the control plane. ` +
|
|
150
|
+
'Re-run with --create to create it again as a new SOP.');
|
|
151
|
+
return 'failed';
|
|
127
152
|
}
|
|
153
|
+
log.info(`${file}: SOP ${id} is gone; creating it again (--create).`);
|
|
128
154
|
}
|
|
129
|
-
|
|
130
|
-
log.error(`${file}:
|
|
131
|
-
|
|
155
|
+
else if (current.status !== 200 || !current.body.sopId) {
|
|
156
|
+
log.error(`${file}: could not read SOP ${id} (${current.status}): ${current.body.message ?? current.body.error ?? 'no detail'}`);
|
|
157
|
+
return 'failed';
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
return updateFile(ctx, filePath, file, raw, parsed, title, current.body);
|
|
132
161
|
}
|
|
133
162
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
163
|
+
return createFile(ctx, filePath, file, raw, parsed, title);
|
|
164
|
+
}
|
|
165
|
+
async function createFile(ctx, filePath, file, raw, parsed, title) {
|
|
166
|
+
let sopId;
|
|
167
|
+
try {
|
|
168
|
+
// The route answers with the created SOP itself; it sends no `ok`.
|
|
169
|
+
const res = await ctx.client.post('/api/v1/sops', {
|
|
170
|
+
title,
|
|
171
|
+
markdown_content: parsed.body,
|
|
172
|
+
// Declared, not guessed: an unstated risk_tier gets the DB's own
|
|
173
|
+
// default (MEDIUM) rather than this CLI choosing LOW.
|
|
174
|
+
risk_tier: parsed.riskTier ?? 'MEDIUM',
|
|
175
|
+
// No front-matter equivalent exists for complexity.
|
|
176
|
+
complexity_tier: 'TIER_0',
|
|
177
|
+
...(parsed.version ? { version: parsed.version } : {}),
|
|
178
|
+
});
|
|
179
|
+
sopId = res?.sopId;
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
log.error(`${file}: failed to push SOP: ${errMessage(err)}`);
|
|
183
|
+
return 'failed';
|
|
184
|
+
}
|
|
185
|
+
if (!sopId) {
|
|
186
|
+
log.error(`${file}: failed to create SOP on the control plane.`);
|
|
187
|
+
return 'failed';
|
|
188
|
+
}
|
|
189
|
+
log.info(`${file} → new workspace SOP "${title}"`);
|
|
190
|
+
log.field('SOP ID', sopId);
|
|
191
|
+
// The create response carries no version counter; read it back so the next
|
|
192
|
+
// push has its precondition. A new SOP starts at 1.
|
|
193
|
+
let revision = 1;
|
|
194
|
+
try {
|
|
195
|
+
const created = await ctx.client.getWithStatus(`/api/v1/sops/${enc(sopId)}`);
|
|
196
|
+
if (created.status === 200 && typeof created.body.versionCounter === 'number')
|
|
197
|
+
revision = created.body.versionCounter;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Keep 1: the SOP was just created, so nothing has edited it yet.
|
|
201
|
+
}
|
|
202
|
+
await recordSync(filePath, file, raw, { intuticId: sopId, revision });
|
|
203
|
+
return 'created';
|
|
204
|
+
}
|
|
205
|
+
async function updateFile(ctx, filePath, file, raw, parsed, title, current) {
|
|
206
|
+
const id = current.sopId;
|
|
207
|
+
const serverRevision = current.versionCounter;
|
|
208
|
+
const sameContent = current.title === title &&
|
|
209
|
+
(current.markdownContent ?? '').trim() === parsed.body &&
|
|
210
|
+
(parsed.riskTier === null || parsed.riskTier === current.riskTier) &&
|
|
211
|
+
(parsed.version === null || parsed.version === current.version);
|
|
212
|
+
if (sameContent) {
|
|
213
|
+
log.info(`${file}: up to date with "${title}" (${id}).`);
|
|
214
|
+
if (typeof serverRevision === 'number' && serverRevision !== parsed.revision) {
|
|
215
|
+
await recordSync(filePath, file, raw, { intuticId: id, revision: serverRevision });
|
|
216
|
+
}
|
|
217
|
+
return 'up-to-date';
|
|
218
|
+
}
|
|
219
|
+
if (!ctx.opts.force && (parsed.revision === null || parsed.revision !== serverRevision)) {
|
|
220
|
+
log.error(`${file}: "${title}" changed on the server since you pulled it ` +
|
|
221
|
+
`(revision ${serverRevision ?? '?'} there, ${parsed.revision ?? 'none'} recorded here). ` +
|
|
222
|
+
'Run `intutic sops pull` to fetch it, or push with --force to overwrite it.');
|
|
223
|
+
return 'failed';
|
|
224
|
+
}
|
|
225
|
+
// Editing an approved SOP forks a new DRAFT version. If one already exists,
|
|
226
|
+
// a second fork would leave two competing drafts of one guideline.
|
|
227
|
+
if (current.lifecycleState && current.lifecycleState !== 'DRAFT' && !ctx.opts.force) {
|
|
228
|
+
const successors = (await successorsOf(ctx)).get(id) ?? [];
|
|
229
|
+
if (successors.length > 0) {
|
|
230
|
+
log.error(`${file}: "${title}" already has a newer DRAFT version on the server (${successors.join(', ')}). ` +
|
|
231
|
+
'Edit that draft instead (`intutic sops pull` writes it to its own file), or push with --force to fork another.');
|
|
232
|
+
return 'failed';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const body = {
|
|
236
|
+
title,
|
|
237
|
+
markdown_content: parsed.body,
|
|
238
|
+
...(parsed.riskTier && parsed.riskTier !== current.riskTier ? { risk_tier: parsed.riskTier } : {}),
|
|
239
|
+
// Only a version the file changed: an unchanged one would pin a forked
|
|
240
|
+
// draft to the approved version's number instead of the server's bump.
|
|
241
|
+
...(parsed.version && parsed.version !== current.version ? { version: parsed.version } : {}),
|
|
242
|
+
...(typeof serverRevision === 'number'
|
|
243
|
+
? { version_counter: ctx.opts.force ? serverRevision : parsed.revision }
|
|
244
|
+
: {}),
|
|
245
|
+
};
|
|
246
|
+
let res;
|
|
247
|
+
try {
|
|
248
|
+
res = await ctx.client.putWithStatus(`/api/v1/sops/${enc(id)}`, body);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
log.error(`${file}: failed to update SOP ${id}: ${errMessage(err)}`);
|
|
252
|
+
return 'failed';
|
|
253
|
+
}
|
|
254
|
+
if (res.status === 409) {
|
|
255
|
+
log.error(`${file}: "${title}" changed on the server since you pulled it (${res.body.error ?? 'version conflict'}). ` +
|
|
256
|
+
'Run `intutic sops pull` to fetch it, or push with --force to overwrite it.');
|
|
257
|
+
return 'failed';
|
|
258
|
+
}
|
|
259
|
+
if (res.status === 403) {
|
|
260
|
+
// WEAKEN_REQUIRES_OWNER (the edit loosens the guideline) or the role check.
|
|
261
|
+
log.error(`${file}: the control plane refused the edit: ${res.body.message ?? res.body.error ?? 'forbidden'}`);
|
|
262
|
+
return 'failed';
|
|
263
|
+
}
|
|
264
|
+
if (res.status === 404) {
|
|
265
|
+
log.error(`${file}: SOP ${id} is no longer on the control plane. Re-run with --create to create it again as a new SOP.`);
|
|
266
|
+
return 'failed';
|
|
267
|
+
}
|
|
268
|
+
const sop = res.body.sop;
|
|
269
|
+
if (res.status !== 200 || !sop?.sopId) {
|
|
270
|
+
log.error(`${file}: update failed (${res.status}): ${res.body.message ?? res.body.error ?? 'no detail'}`);
|
|
271
|
+
return 'failed';
|
|
272
|
+
}
|
|
273
|
+
const forked = sop.sopId !== id;
|
|
274
|
+
const revision = typeof sop.versionCounter === 'number' ? sop.versionCounter : (serverRevision ?? 0) + 1;
|
|
275
|
+
const extra = {};
|
|
276
|
+
if (parsed.version !== null && sop.version && sop.version !== parsed.version)
|
|
277
|
+
extra.version = sop.version;
|
|
278
|
+
await recordSync(filePath, file, raw, { intuticId: sop.sopId, revision }, extra);
|
|
279
|
+
if (forked) {
|
|
280
|
+
log.info(`${file} → new DRAFT version of "${title}"`);
|
|
281
|
+
log.field('SOP ID', sop.sopId);
|
|
282
|
+
log.dim(` ${id} (${current.lifecycleState}) keeps enforcing until the draft is promoted.`);
|
|
283
|
+
return 'forked';
|
|
284
|
+
}
|
|
285
|
+
log.info(`${file} → updated "${title}" in place`);
|
|
286
|
+
log.field('SOP ID', sop.sopId);
|
|
287
|
+
return 'updated';
|
|
288
|
+
}
|
|
289
|
+
/** Write the sync record into the file, keeping everything else; a failure is reported, not fatal — the server write already happened. */
|
|
290
|
+
async function recordSync(filePath, file, raw, record, extra = {}) {
|
|
291
|
+
try {
|
|
292
|
+
await fs.writeFile(filePath, withSyncRecord(raw, record, extra), 'utf-8');
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
log.warn(`${file}: pushed, but could not record intutic_id ${record.intuticId} in the file: ${errMessage(err)}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function successorsOf(ctx) {
|
|
299
|
+
if (ctx.successorsOf)
|
|
300
|
+
return ctx.successorsOf;
|
|
301
|
+
const map = new Map();
|
|
302
|
+
try {
|
|
303
|
+
for (const item of await listAllSops(ctx.client)) {
|
|
304
|
+
if (!item.priorVersionId)
|
|
305
|
+
continue;
|
|
306
|
+
map.set(item.priorVersionId, [...(map.get(item.priorVersionId) ?? []), item.sopId]);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Without the listing the check cannot run; the server's own
|
|
311
|
+
// precondition still applies to the PUT.
|
|
312
|
+
}
|
|
313
|
+
ctx.successorsOf = map;
|
|
314
|
+
return map;
|
|
315
|
+
}
|
|
316
|
+
/** The route's own cap on `limit`. */
|
|
317
|
+
const SOP_PAGE_SIZE = 100;
|
|
318
|
+
/**
|
|
319
|
+
* Every SOP in the workspace. `GET /api/v1/sops` pages by `page` and `limit`
|
|
320
|
+
* (at most 100) and reports `total`, so one request stopped at the first 100.
|
|
321
|
+
*/
|
|
322
|
+
async function listAllSops(client) {
|
|
323
|
+
const all = [];
|
|
324
|
+
for (let page = 1;; page++) {
|
|
325
|
+
const res = await client.get(`/api/v1/sops?page=${page}&limit=${SOP_PAGE_SIZE}`);
|
|
326
|
+
const items = res.items ?? [];
|
|
327
|
+
// A control plane that ignores `page` would hand back page 1 forever.
|
|
328
|
+
if (items.length > 0 && all.some((s) => s.sopId === items[0].sopId))
|
|
329
|
+
return all;
|
|
330
|
+
all.push(...items);
|
|
331
|
+
if (items.length < SOP_PAGE_SIZE)
|
|
332
|
+
return all;
|
|
333
|
+
if (typeof res.total === 'number' && all.length >= res.total)
|
|
334
|
+
return all;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Every SOP file under `.intutic/sops/`: the flat `*.md` files `sops pull`
|
|
339
|
+
* writes and the `<name>/*.md` folder layout `sops push <name>` also reads.
|
|
340
|
+
* `guardrail-*.md` are `intutic guardrails pull`'s and have no SOP behind
|
|
341
|
+
* them. Empty when the directory does not exist.
|
|
342
|
+
*/
|
|
343
|
+
async function readLocalSops(sopsDir) {
|
|
344
|
+
let entries;
|
|
345
|
+
try {
|
|
346
|
+
entries = await fs.readdir(sopsDir, { withFileTypes: true });
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
const out = [];
|
|
352
|
+
const add = async (path, label) => {
|
|
353
|
+
const raw = await fs.readFile(path, 'utf-8').catch(() => null);
|
|
354
|
+
if (raw !== null)
|
|
355
|
+
out.push({ path, label, raw, parsed: parseSopFile(raw) });
|
|
356
|
+
};
|
|
357
|
+
for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
358
|
+
if (e.isFile() && e.name.endsWith('.md') && !e.name.startsWith('guardrail-')) {
|
|
359
|
+
await add(join(sopsDir, e.name), e.name);
|
|
360
|
+
}
|
|
361
|
+
else if (e.isDirectory()) {
|
|
362
|
+
const inner = await fs.readdir(join(sopsDir, e.name)).catch(() => []);
|
|
363
|
+
for (const f of inner.filter((n) => n.endsWith('.md')).sort())
|
|
364
|
+
await add(join(sopsDir, e.name, f), `${e.name}/${f}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
137
368
|
}
|
|
138
369
|
/**
|
|
139
|
-
* `intutic sops pull` —
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* `
|
|
370
|
+
* `intutic sops pull` — every workspace SOP on the control plane, written
|
|
371
|
+
* with front matter reconstructed from the row (`title:`, `risk_tier:`,
|
|
372
|
+
* `version:`) plus the sync record `sops push` reads: `intutic_id:` (the
|
|
373
|
+
* SOP) and `intutic_revision:` (its `version_counter`).
|
|
374
|
+
*
|
|
375
|
+
* A SOP lands in the local file that already records its id — flat or in a
|
|
376
|
+
* `<name>/` folder — and otherwise in `.intutic/sops/<slug-of-title>.md`. An
|
|
377
|
+
* existing file keeps its other front-matter lines (the enforcing keys the
|
|
378
|
+
* proxy reads); only the synced keys and the body are replaced. An approved
|
|
379
|
+
* version is skipped when a local file already tracks its DRAFT successor,
|
|
380
|
+
* so a push that forked a draft does not come back as a second file.
|
|
143
381
|
*
|
|
144
382
|
* Refuses to overwrite a file it did not itself write most recently, unless
|
|
145
383
|
* `--force` is passed. The check is a recorded hash, not a live diff: every
|
|
146
|
-
* file this command writes carries `content_hash: sha256(body)
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* pull; a match means nothing local changed, so pulling fresh content from
|
|
151
|
-
* the plane (which may itself have changed) is safe. A file with no
|
|
152
|
-
* recorded hash at all — hand-authored, or only ever pushed — is treated as
|
|
153
|
-
* unverifiable and always requires `--force`, since overwriting a
|
|
154
|
-
* hand-written SOP the first time this command sees it would be the
|
|
155
|
-
* expensive direction to get wrong.
|
|
384
|
+
* file this command writes carries `content_hash: sha256(body)`, and a later
|
|
385
|
+
* pull compares that against the CURRENT local body's hash. A mismatch means
|
|
386
|
+
* a human edited the file since; a file with no recorded hash at all —
|
|
387
|
+
* hand-authored — is treated as unverifiable and always requires `--force`.
|
|
156
388
|
*/
|
|
157
389
|
export async function runSopsPull(opts) {
|
|
158
390
|
log.header('Intutic — Pull SOPs');
|
|
@@ -162,8 +394,7 @@ export async function runSopsPull(opts) {
|
|
|
162
394
|
const client = await getClient(opts.dev);
|
|
163
395
|
let items;
|
|
164
396
|
try {
|
|
165
|
-
|
|
166
|
-
items = res.items ?? [];
|
|
397
|
+
items = await listAllSops(client);
|
|
167
398
|
}
|
|
168
399
|
catch (err) {
|
|
169
400
|
log.error(`Failed to list SOPs: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -174,10 +405,27 @@ export async function runSopsPull(opts) {
|
|
|
174
405
|
return;
|
|
175
406
|
}
|
|
176
407
|
await fs.mkdir(sopsDir, { recursive: true });
|
|
408
|
+
const local = await readLocalSops(sopsDir);
|
|
409
|
+
const fileById = new Map();
|
|
410
|
+
for (const f of local)
|
|
411
|
+
if (f.parsed.intuticId)
|
|
412
|
+
fileById.set(f.parsed.intuticId, f);
|
|
413
|
+
// An approved version whose DRAFT successor a local file tracks.
|
|
414
|
+
const supersededBy = new Map();
|
|
415
|
+
for (const item of items) {
|
|
416
|
+
const tracked = fileById.get(item.sopId);
|
|
417
|
+
if (item.priorVersionId && tracked)
|
|
418
|
+
supersededBy.set(item.priorVersionId, tracked.label);
|
|
419
|
+
}
|
|
177
420
|
let written = 0;
|
|
178
421
|
let conflicts = 0;
|
|
179
422
|
const seenSlugs = new Set();
|
|
180
423
|
for (const item of items) {
|
|
424
|
+
const tracked = fileById.get(item.sopId);
|
|
425
|
+
if (!tracked && supersededBy.has(item.sopId)) {
|
|
426
|
+
log.dim(` ${item.title} (${item.sopId}): skipped, ${supersededBy.get(item.sopId)} tracks its newer version.`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
181
429
|
let detail;
|
|
182
430
|
try {
|
|
183
431
|
detail = await client.get(`/api/v1/sops/${item.sopId}`);
|
|
@@ -190,20 +438,23 @@ export async function runSopsPull(opts) {
|
|
|
190
438
|
log.warn(`${item.title}: no content on the control plane, skipping.`);
|
|
191
439
|
continue;
|
|
192
440
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
441
|
+
let filePath;
|
|
442
|
+
if (tracked) {
|
|
443
|
+
filePath = tracked.path;
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
// Two SOPs with the same title collide on the same file, as does a file
|
|
447
|
+
// that records a different SOP. The second gets its sopId suffixed — a
|
|
448
|
+
// visible, if ugly, signal beats a quiet data loss.
|
|
449
|
+
let slug = slugifyTitle(detail.title);
|
|
450
|
+
const claimed = (s) => seenSlugs.has(s) || local.some((f) => f.path === join(sopsDir, `${s}.md`) && f.parsed.intuticId !== null && f.parsed.intuticId !== detail.sopId);
|
|
451
|
+
if (claimed(slug))
|
|
452
|
+
slug = `${slug}-${detail.sopId.slice(-6)}`;
|
|
453
|
+
seenSlugs.add(slug);
|
|
454
|
+
filePath = join(sopsDir, `${slug}.md`);
|
|
455
|
+
}
|
|
456
|
+
const body = detail.markdownContent.trim();
|
|
457
|
+
const record = { intuticId: detail.sopId, revision: detail.versionCounter ?? item.versionCounter ?? 1 };
|
|
207
458
|
const existing = await fs.readFile(filePath, 'utf-8').catch(() => null);
|
|
208
459
|
if (existing !== null && !opts.force) {
|
|
209
460
|
const parsedExisting = parseSopFile(existing);
|
|
@@ -215,6 +466,16 @@ export async function runSopsPull(opts) {
|
|
|
215
466
|
continue;
|
|
216
467
|
}
|
|
217
468
|
}
|
|
469
|
+
const rendered = existing === null
|
|
470
|
+
? renderSopFile({ title: detail.title, riskTier: detail.riskTier, version: detail.version, body, record })
|
|
471
|
+
: setFrontMatter(existing, {
|
|
472
|
+
title: detail.title,
|
|
473
|
+
risk_tier: detail.riskTier,
|
|
474
|
+
version: detail.version,
|
|
475
|
+
content_hash: createHash('sha256').update(body, 'utf8').digest('hex'),
|
|
476
|
+
intutic_id: record.intuticId,
|
|
477
|
+
intutic_revision: String(record.revision),
|
|
478
|
+
}, body);
|
|
218
479
|
await fs.writeFile(filePath, rendered, 'utf-8');
|
|
219
480
|
written += 1;
|
|
220
481
|
log.info(`${filePath} ← "${detail.title}"`);
|
|
@@ -225,52 +486,47 @@ export async function runSopsPull(opts) {
|
|
|
225
486
|
}
|
|
226
487
|
}
|
|
227
488
|
/**
|
|
228
|
-
* `intutic sops status` — drift between
|
|
229
|
-
* control plane, read-only against the file
|
|
230
|
-
*
|
|
489
|
+
* `intutic sops status` — drift between the local SOP files (flat and
|
|
490
|
+
* `<name>/` folders) and the control plane, read-only against the file
|
|
491
|
+
* system.
|
|
231
492
|
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* edited after an old pull"
|
|
493
|
+
* A file that records its SOP (`intutic_id:`) is compared with that SOP; a
|
|
494
|
+
* file without one is matched by title, the heuristic `pull` used before
|
|
495
|
+
* files carried ids. `intutic_revision:` tells whether the server moved since
|
|
496
|
+
* the file was synced, so a file edited on both sides reports `diverged`
|
|
497
|
+
* rather than `local-ahead`. A file with no `content_hash:` marker cannot be
|
|
498
|
+
* told apart as "never pulled" or "edited after an old pull", so it reports
|
|
499
|
+
* `diverged` when its content differs.
|
|
238
500
|
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* second project", per `apps/docs/guide/gitops-sops.md`), but a human or CI
|
|
244
|
-
* job running this command on a schedule now leaves the compliance probe
|
|
245
|
-
* (`sop_git_drift`) something to read instead of nothing. Report failures
|
|
246
|
-
* never fail the command — the read-only status output above is already
|
|
247
|
-
* complete and correct on its own.
|
|
501
|
+
* Every matched (non-`push-only`) result is reported to
|
|
502
|
+
* `POST /api/v1/sops/git-drift-report`, best-effort, so the compliance probe
|
|
503
|
+
* (`sop_git_drift`) has something to read. Report failures never fail the
|
|
504
|
+
* command.
|
|
248
505
|
*/
|
|
249
506
|
export async function runSopsStatus(opts) {
|
|
250
507
|
log.header('Intutic — SOP Drift Status');
|
|
251
508
|
const config = loadConfig();
|
|
252
509
|
const workspaceRoot = config?.workspaceRoot ?? process.cwd();
|
|
253
510
|
const sopsDir = join(workspaceRoot, '.intutic', 'sops');
|
|
254
|
-
let localFiles;
|
|
255
511
|
try {
|
|
256
|
-
|
|
257
|
-
// control-plane SOP to drift against; listing them here would report every
|
|
258
|
-
// one as push-only.
|
|
259
|
-
localFiles = (await fs.readdir(sopsDir)).filter((f) => f.endsWith('.md') && !f.startsWith('guardrail-')).sort();
|
|
512
|
+
await fs.access(sopsDir);
|
|
260
513
|
}
|
|
261
514
|
catch {
|
|
262
515
|
log.info(`No local SOPs directory at ${sopsDir}.`);
|
|
263
516
|
return;
|
|
264
517
|
}
|
|
518
|
+
const localFiles = await readLocalSops(sopsDir);
|
|
265
519
|
if (localFiles.length === 0) {
|
|
266
520
|
log.info(`No .md files in ${sopsDir}.`);
|
|
267
521
|
return;
|
|
268
522
|
}
|
|
269
523
|
const client = await getClient(opts.dev);
|
|
524
|
+
let remoteById;
|
|
270
525
|
let remoteByTitle;
|
|
271
526
|
try {
|
|
272
|
-
const
|
|
273
|
-
|
|
527
|
+
const items = await listAllSops(client);
|
|
528
|
+
remoteById = new Map(items.map((r) => [r.sopId, r]));
|
|
529
|
+
remoteByTitle = new Map(items.map((r) => [r.title, r]));
|
|
274
530
|
}
|
|
275
531
|
catch (err) {
|
|
276
532
|
log.error(`Failed to fetch workspace SOPs: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -278,34 +534,35 @@ export async function runSopsStatus(opts) {
|
|
|
278
534
|
}
|
|
279
535
|
const counts = { 'in-sync': 0, 'local-ahead': 0, 'remote-ahead': 0, diverged: 0, 'push-only': 0 };
|
|
280
536
|
const driftReport = [];
|
|
281
|
-
for (const file of localFiles) {
|
|
282
|
-
const
|
|
283
|
-
const parsed = parseSopFile(raw);
|
|
284
|
-
const title = parsed.title ?? titleFromFileName(file);
|
|
537
|
+
for (const { label: file, parsed } of localFiles) {
|
|
538
|
+
const title = parsed.title ?? titleFromFileName(file.slice(file.lastIndexOf('/') + 1));
|
|
285
539
|
const localHash = createHash('sha256').update(parsed.body, 'utf8').digest('hex');
|
|
286
|
-
const remote = remoteByTitle.get(title);
|
|
540
|
+
const remote = parsed.intuticId ? remoteById.get(parsed.intuticId) : remoteByTitle.get(title);
|
|
287
541
|
if (!remote) {
|
|
288
542
|
counts['push-only'] += 1;
|
|
289
|
-
log.field(file,
|
|
543
|
+
log.field(file, parsed.intuticId
|
|
544
|
+
? `push-only — its SOP ${parsed.intuticId} is not active on the control plane (deleted, or replaced by a promoted version); \`sops push --create\` creates it again`
|
|
545
|
+
: `push-only — no SOP titled "${title}" on the control plane yet`);
|
|
290
546
|
continue;
|
|
291
547
|
}
|
|
292
548
|
const remoteHash = remote.contentHash;
|
|
549
|
+
const remoteMoved = parsed.revision !== null && typeof remote.versionCounter === 'number' && remote.versionCounter !== parsed.revision;
|
|
293
550
|
let status;
|
|
294
551
|
if (remoteHash && localHash === remoteHash) {
|
|
295
552
|
status = 'in-sync';
|
|
296
553
|
}
|
|
297
554
|
else if (parsed.contentHash === localHash) {
|
|
298
|
-
// Unedited since the last
|
|
299
|
-
//
|
|
555
|
+
// Unedited since the last sync, but no longer what the control plane
|
|
556
|
+
// holds — the control plane moved, this file did not.
|
|
300
557
|
status = 'remote-ahead';
|
|
301
558
|
}
|
|
302
559
|
else if (parsed.contentHash !== null) {
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
status = 'local-ahead';
|
|
560
|
+
// Edited since the last sync. If the server's revision moved too, both
|
|
561
|
+
// sides changed.
|
|
562
|
+
status = remoteMoved ? 'diverged' : 'local-ahead';
|
|
306
563
|
}
|
|
307
564
|
else {
|
|
308
|
-
// No
|
|
565
|
+
// No sync marker at all, and it does not match the control plane
|
|
309
566
|
// either: cannot tell "never pulled" apart from "edited since a very
|
|
310
567
|
// old pull" from local information alone.
|
|
311
568
|
status = 'diverged';
|
|
@@ -316,7 +573,9 @@ export async function runSopsStatus(opts) {
|
|
|
316
573
|
'in-sync': 'in sync',
|
|
317
574
|
'local-ahead': 'locally modified — `sops push` to publish',
|
|
318
575
|
'remote-ahead': 'control plane has newer content — `sops pull` to fetch',
|
|
319
|
-
diverged:
|
|
576
|
+
diverged: remoteMoved
|
|
577
|
+
? 'changed both here and on the control plane — `sops pull --force` or `sops push --force` picks one'
|
|
578
|
+
: 'diverges from the control plane, and was never (verifiably) pulled',
|
|
320
579
|
'push-only': '',
|
|
321
580
|
}[status];
|
|
322
581
|
log.field(file, `${status} — ${label}`);
|