@starci/skills 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INDEX.md +74 -0
- package/INDEX.vi.md +75 -0
- package/README.md +44 -0
- package/README.vi.md +43 -0
- package/SKILL.md +135 -0
- package/SKILL.vi.md +128 -0
- package/alias/INDEX.md +104 -0
- package/alias/INDEX.vi.md +104 -0
- package/alias/alias.json +297 -0
- package/bin/starci-skills.mjs +219 -0
- package/knowledge/INDEX.md +22 -0
- package/knowledge/INDEX.vi.md +22 -0
- package/knowledge/grammars/starci/DNA.md +146 -0
- package/knowledge/grammars/starci/DNA.vi.md +146 -0
- package/knowledge/grammars/starci/INDEX.md +25 -0
- package/knowledge/grammars/starci/INDEX.vi.md +25 -0
- package/knowledge/grammars/starci/family.md +50 -0
- package/knowledge/grammars/starci/family.vi.md +50 -0
- package/knowledge/grammars/starci/idioms.md +120 -0
- package/knowledge/grammars/starci/idioms.vi.md +121 -0
- package/knowledge/grammars/starci/playbook.md +36 -0
- package/knowledge/grammars/starci/playbook.vi.md +35 -0
- package/knowledge/patterns/be/INDEX.md +29 -0
- package/knowledge/patterns/be/INDEX.vi.md +29 -0
- package/knowledge/patterns/be/comment.md +80 -0
- package/knowledge/patterns/be/comment.vi.md +80 -0
- package/knowledge/patterns/be/error.md +86 -0
- package/knowledge/patterns/be/error.vi.md +86 -0
- package/knowledge/patterns/be/folder.md +87 -0
- package/knowledge/patterns/be/folder.vi.md +86 -0
- package/knowledge/patterns/be/function.md +80 -0
- package/knowledge/patterns/be/function.vi.md +80 -0
- package/knowledge/patterns/be/imports.md +79 -0
- package/knowledge/patterns/be/imports.vi.md +79 -0
- package/knowledge/patterns/be/naming.md +87 -0
- package/knowledge/patterns/be/naming.vi.md +87 -0
- package/knowledge/patterns/be/test.md +79 -0
- package/knowledge/patterns/be/test.vi.md +79 -0
- package/knowledge/patterns/be/typing.md +73 -0
- package/knowledge/patterns/be/typing.vi.md +73 -0
- package/knowledge/patterns/fe/INDEX.md +29 -0
- package/knowledge/patterns/fe/INDEX.vi.md +29 -0
- package/knowledge/patterns/fe/comment.md +70 -0
- package/knowledge/patterns/fe/comment.vi.md +70 -0
- package/knowledge/patterns/fe/error.md +61 -0
- package/knowledge/patterns/fe/error.vi.md +61 -0
- package/knowledge/patterns/fe/folder.md +98 -0
- package/knowledge/patterns/fe/folder.vi.md +95 -0
- package/knowledge/patterns/fe/function.md +70 -0
- package/knowledge/patterns/fe/function.vi.md +70 -0
- package/knowledge/patterns/fe/imports.md +87 -0
- package/knowledge/patterns/fe/imports.vi.md +87 -0
- package/knowledge/patterns/fe/naming.md +77 -0
- package/knowledge/patterns/fe/naming.vi.md +77 -0
- package/knowledge/patterns/fe/test.md +73 -0
- package/knowledge/patterns/fe/test.vi.md +73 -0
- package/knowledge/patterns/fe/typing.md +67 -0
- package/knowledge/patterns/fe/typing.vi.md +67 -0
- package/knowledge/ui/INDEX.md +101 -0
- package/knowledge/ui/INDEX.vi.md +101 -0
- package/knowledge/ui/composition/INDEX.md +60 -0
- package/knowledge/ui/composition/INDEX.vi.md +63 -0
- package/knowledge/ui/composition/accent.md +73 -0
- package/knowledge/ui/composition/accent.vi.md +74 -0
- package/knowledge/ui/composition/action.md +55 -0
- package/knowledge/ui/composition/action.vi.md +56 -0
- package/knowledge/ui/composition/coverage.md +37 -0
- package/knowledge/ui/composition/coverage.vi.md +37 -0
- package/knowledge/ui/composition/cta.md +79 -0
- package/knowledge/ui/composition/cta.vi.md +79 -0
- package/knowledge/ui/composition/feedback.md +55 -0
- package/knowledge/ui/composition/feedback.vi.md +56 -0
- package/knowledge/ui/composition/hierarchy.md +83 -0
- package/knowledge/ui/composition/hierarchy.vi.md +82 -0
- package/knowledge/ui/composition/layout.md +91 -0
- package/knowledge/ui/composition/layout.vi.md +93 -0
- package/knowledge/ui/composition/responsive.md +67 -0
- package/knowledge/ui/composition/responsive.vi.md +67 -0
- package/knowledge/ui/composition/state.md +105 -0
- package/knowledge/ui/composition/state.vi.md +106 -0
- package/knowledge/ui/presentation/INDEX.md +94 -0
- package/knowledge/ui/presentation/INDEX.vi.md +69 -0
- package/knowledge/ui/presentation/boundary.md +170 -0
- package/knowledge/ui/presentation/boundary.vi.md +169 -0
- package/knowledge/ui/presentation/font.md +155 -0
- package/knowledge/ui/presentation/font.vi.md +156 -0
- package/knowledge/ui/presentation/gap.md +181 -0
- package/knowledge/ui/presentation/gap.vi.md +181 -0
- package/knowledge/ui/presentation/margin.md +168 -0
- package/knowledge/ui/presentation/margin.vi.md +171 -0
- package/knowledge/ui/presentation/measure.md +174 -0
- package/knowledge/ui/presentation/measure.vi.md +178 -0
- package/knowledge/ui/presentation/overflow.md +142 -0
- package/knowledge/ui/presentation/overflow.vi.md +142 -0
- package/knowledge/ui/presentation/padding.md +293 -0
- package/knowledge/ui/presentation/padding.vi.md +292 -0
- package/knowledge/ui/presentation/surface.md +166 -0
- package/knowledge/ui/presentation/surface.vi.md +166 -0
- package/knowledge/ui/presentation/text-flow.md +123 -0
- package/knowledge/ui/presentation/text-flow.vi.md +123 -0
- package/knowledge/ui/presentation/tone.md +114 -0
- package/knowledge/ui/presentation/tone.vi.md +113 -0
- package/knowledge/ui/proof/INDEX.md +55 -0
- package/knowledge/ui/proof/INDEX.vi.md +55 -0
- package/knowledge/ui/proof/accessibility.md +67 -0
- package/knowledge/ui/proof/accessibility.vi.md +68 -0
- package/knowledge/ui/proof/contrast.md +58 -0
- package/knowledge/ui/proof/contrast.vi.md +57 -0
- package/knowledge/ui/proof/focus.md +76 -0
- package/knowledge/ui/proof/focus.vi.md +76 -0
- package/knowledge/ui/proof/motion.md +63 -0
- package/knowledge/ui/proof/motion.vi.md +64 -0
- package/knowledge/ui/proof/render-truth.md +63 -0
- package/knowledge/ui/proof/render-truth.vi.md +63 -0
- package/operators/INDEX.md +199 -0
- package/operators/INDEX.vi.md +199 -0
- package/operators/architecture-decide/errors.json +115 -0
- package/operators/architecture-decide/operator.json +20 -0
- package/operators/architecture-decide/operator.md +133 -0
- package/operators/architecture-decide/operator.vi.md +130 -0
- package/operators/architecture-decide/self-test.mjs +221 -0
- package/operators/architecture-decide/validate.mjs +112 -0
- package/operators/backend-source-apply/errors.json +66 -0
- package/operators/backend-source-apply/operator.json +20 -0
- package/operators/backend-source-apply/operator.md +189 -0
- package/operators/backend-source-apply/operator.vi.md +185 -0
- package/operators/backend-source-apply/self-test.mjs +257 -0
- package/operators/backend-source-apply/validate.mjs +240 -0
- package/operators/business-decide/errors.json +78 -0
- package/operators/business-decide/operator.json +20 -0
- package/operators/business-decide/operator.md +176 -0
- package/operators/business-decide/operator.vi.md +174 -0
- package/operators/business-decide/self-test.mjs +225 -0
- package/operators/business-decide/validate.mjs +277 -0
- package/operators/content-generate/errors.json +106 -0
- package/operators/content-generate/operator.json +21 -0
- package/operators/content-generate/operator.md +155 -0
- package/operators/content-generate/operator.vi.md +155 -0
- package/operators/content-generate/self-test.mjs +288 -0
- package/operators/content-generate/validate.mjs +196 -0
- package/operators/errors.json +178 -0
- package/operators/frontend-direction-decide/errors.json +135 -0
- package/operators/frontend-direction-decide/operator.json +21 -0
- package/operators/frontend-direction-decide/operator.md +167 -0
- package/operators/frontend-direction-decide/operator.vi.md +167 -0
- package/operators/frontend-direction-decide/self-test.mjs +226 -0
- package/operators/frontend-direction-decide/validate.mjs +146 -0
- package/operators/frontend-presentation-resolve/errors.json +42 -0
- package/operators/frontend-presentation-resolve/operator.json +19 -0
- package/operators/frontend-presentation-resolve/operator.md +142 -0
- package/operators/frontend-presentation-resolve/operator.vi.md +140 -0
- package/operators/frontend-presentation-resolve/self-test.mjs +163 -0
- package/operators/frontend-presentation-resolve/validate.mjs +143 -0
- package/operators/frontend-source-apply/errors.json +30 -0
- package/operators/frontend-source-apply/operator.json +20 -0
- package/operators/frontend-source-apply/operator.md +127 -0
- package/operators/frontend-source-apply/operator.vi.md +126 -0
- package/operators/frontend-source-apply/self-test.mjs +214 -0
- package/operators/frontend-source-apply/validate.mjs +133 -0
- package/operators/frontend-surface-audit/errors.json +5 -0
- package/operators/frontend-surface-audit/operator.json +22 -0
- package/operators/frontend-surface-audit/operator.md +121 -0
- package/operators/frontend-surface-audit/operator.vi.md +121 -0
- package/operators/frontend-surface-audit/self-test.mjs +185 -0
- package/operators/frontend-surface-audit/validate.mjs +127 -0
- package/operators/git-publish/errors.json +54 -0
- package/operators/git-publish/operator.json +20 -0
- package/operators/git-publish/operator.md +180 -0
- package/operators/git-publish/operator.vi.md +178 -0
- package/operators/git-publish/self-test.mjs +177 -0
- package/operators/git-publish/validate.mjs +106 -0
- package/operators/platform-operate/errors.json +90 -0
- package/operators/platform-operate/operator.json +22 -0
- package/operators/platform-operate/operator.md +162 -0
- package/operators/platform-operate/operator.vi.md +160 -0
- package/operators/platform-operate/self-test.mjs +202 -0
- package/operators/platform-operate/validate.mjs +193 -0
- package/operators/quality-verify/errors.json +54 -0
- package/operators/quality-verify/operator.json +20 -0
- package/operators/quality-verify/operator.md +185 -0
- package/operators/quality-verify/operator.vi.md +177 -0
- package/operators/quality-verify/self-test.mjs +210 -0
- package/operators/quality-verify/validate.mjs +197 -0
- package/operators/release-deploy/errors.json +158 -0
- package/operators/release-deploy/operator.json +23 -0
- package/operators/release-deploy/operator.md +183 -0
- package/operators/release-deploy/operator.vi.md +181 -0
- package/operators/release-deploy/self-test.mjs +235 -0
- package/operators/release-deploy/validate.mjs +129 -0
- package/operators/uat-verify/errors.json +78 -0
- package/operators/uat-verify/operator.json +25 -0
- package/operators/uat-verify/operator.md +162 -0
- package/operators/uat-verify/operator.vi.md +161 -0
- package/operators/uat-verify/self-test.mjs +270 -0
- package/operators/uat-verify/validate.mjs +202 -0
- package/operators/workspace-bind/errors.json +90 -0
- package/operators/workspace-bind/operator.json +21 -0
- package/operators/workspace-bind/operator.md +148 -0
- package/operators/workspace-bind/operator.vi.md +148 -0
- package/operators/workspace-bind/self-test.mjs +205 -0
- package/operators/workspace-bind/validate.mjs +152 -0
- package/package.json +50 -0
- package/readiness/initialization/workspaces/commit-policy.json +91 -0
- package/readiness/initialization/workspaces/config.schema.json +35 -0
- package/readiness/initialization/workspaces/device-state.schema.json +54 -0
- package/readiness/initialization/workspaces/local-route.schema.json +206 -0
- package/readiness/initialization/workspaces/portable-route.schema.json +200 -0
- package/resources/INDEX.md +96 -0
- package/resources/INDEX.vi.md +99 -0
- package/resources/agents/profiles/claude.json +131 -0
- package/resources/agents/profiles/openai.json +131 -0
- package/resources/orchestrator.json +71 -0
- package/resources/tools.json +85 -0
- package/routing.json +272 -0
- package/scripts/alias-registry.mjs +31 -0
- package/scripts/device-state.mjs +497 -0
- package/scripts/device-state.spec.mjs +18 -0
- package/scripts/errors-registry.mjs +68 -0
- package/scripts/generate-alias-doc.mjs +66 -0
- package/scripts/generate-grammar-dna.mjs +387 -0
- package/scripts/generate-operators-index.mjs +95 -0
- package/scripts/generate-presentation-owned.mjs +681 -0
- package/scripts/install-cli.spec.mjs +74 -0
- package/scripts/json-schema.mjs +94 -0
- package/scripts/operator-md.mjs +96 -0
- package/scripts/run-operator-self-tests.mjs +36 -0
- package/scripts/validate-alias.mjs +165 -0
- package/scripts/validate-defaults.mjs +72 -0
- package/scripts/validate-knowledge-citations.mjs +90 -0
- package/scripts/validate-operator.mjs +125 -0
- package/scripts/validate-request.mjs +80 -0
- package/scripts/validate-resources.mjs +117 -0
- package/scripts/validate-response.mjs +141 -0
- package/scripts/validate-routing.mjs +91 -0
- package/scripts/validate-step.mjs +50 -0
- package/scripts/validate-templates.mjs +226 -0
- package/scripts/validate-templates.spec.mjs +144 -0
- package/scripts/validate-workflows.mjs +106 -0
- package/scripts/workspace-portable.mjs +389 -0
- package/scripts/workspace-portable.spec.mjs +246 -0
- package/templates/README.md +47 -0
- package/templates/README.vi.md +45 -0
- package/templates/changes.example.md +27 -0
- package/templates/grammars.template.md +34 -0
- package/templates/kinds/architecture-decision.contract.json +14 -0
- package/templates/kinds/architecture-decision.skeleton.md +52 -0
- package/templates/kinds/backend-source-application.contract.json +10 -0
- package/templates/kinds/backend-source-application.skeleton.md +34 -0
- package/templates/kinds/business-promise-authority.contract.json +13 -0
- package/templates/kinds/business-promise-authority.skeleton.md +67 -0
- package/templates/kinds/capture.schema.json +33 -0
- package/templates/kinds/changes.contract.json +10 -0
- package/templates/kinds/changes.skeleton.md +26 -0
- package/templates/kinds/checks.schema.json +44 -0
- package/templates/kinds/claims.schema.json +165 -0
- package/templates/kinds/conformance.schema.json +15 -0
- package/templates/kinds/content-brief.contract.json +12 -0
- package/templates/kinds/content-brief.skeleton.md +45 -0
- package/templates/kinds/content-generation-receipt.contract.json +11 -0
- package/templates/kinds/content-generation-receipt.skeleton.md +40 -0
- package/templates/kinds/content-review.contract.json +11 -0
- package/templates/kinds/content-review.skeleton.md +45 -0
- package/templates/kinds/contract.schema.json +28 -0
- package/templates/kinds/coverage-matrix.schema.json +86 -0
- package/templates/kinds/coverage.schema.json +62 -0
- package/templates/kinds/current-state.schema.json +40 -0
- package/templates/kinds/delta.schema.json +95 -0
- package/templates/kinds/e2e.schema.json +63 -0
- package/templates/kinds/frontend-direction-decision.contract.json +93 -0
- package/templates/kinds/frontend-direction-decision.skeleton.md +68 -0
- package/templates/kinds/frontend-presentation-resolution.contract.json +11 -0
- package/templates/kinds/frontend-presentation-resolution.skeleton.md +32 -0
- package/templates/kinds/frontend-source-application.contract.json +10 -0
- package/templates/kinds/frontend-source-application.skeleton.md +30 -0
- package/templates/kinds/frontend-surface-audit.contract.json +52 -0
- package/templates/kinds/frontend-surface-audit.skeleton.md +32 -0
- package/templates/kinds/gate-result.schema.json +64 -0
- package/templates/kinds/git-publication.contract.json +82 -0
- package/templates/kinds/git-publication.skeleton.md +61 -0
- package/templates/kinds/independent-critique.contract.json +9 -0
- package/templates/kinds/independent-critique.skeleton.md +28 -0
- package/templates/kinds/inventory.schema.json +35 -0
- package/templates/kinds/model.schema.json +99 -0
- package/templates/kinds/mutations.schema.json +65 -0
- package/templates/kinds/platform-operation-receipt.contract.json +76 -0
- package/templates/kinds/platform-operation-receipt.skeleton.md +54 -0
- package/templates/kinds/probes.schema.json +130 -0
- package/templates/kinds/proof.schema.json +17 -0
- package/templates/kinds/quality-verification.contract.json +87 -0
- package/templates/kinds/quality-verification.skeleton.md +58 -0
- package/templates/kinds/release-deployment.contract.json +85 -0
- package/templates/kinds/release-deployment.skeleton.md +67 -0
- package/templates/kinds/route.schema.json +293 -0
- package/templates/kinds/stack-model.schema.json +90 -0
- package/templates/kinds/uat-capture.schema.json +35 -0
- package/templates/kinds/uat-flow-verification.contract.json +11 -0
- package/templates/kinds/uat-flow-verification.skeleton.md +47 -0
- package/templates/kinds/uat-snapshot.schema.json +96 -0
- package/templates/kinds/uat-verdicts.schema.json +41 -0
- package/templates/kinds/ui-coverage.schema.json +77 -0
- package/templates/kinds/verdicts.schema.json +39 -0
- package/templates/kinds/workspace-route-binding.contract.json +12 -0
- package/templates/kinds/workspace-route-binding.skeleton.md +60 -0
- package/templates/kinds/writes.schema.json +37 -0
- package/templates/operator.template.md +73 -0
- package/templates/patterns.template.md +31 -0
- package/templates/step/request.schema.json +42 -0
- package/templates/step/response.schema.json +144 -0
- package/templates/ui-composition.template.md +36 -0
- package/templates/ui-presentation.template.md +57 -0
- package/templates/ui-proof.template.md +34 -0
- package/workflows/README.md +37 -0
- package/workflows/README.vi.md +37 -0
- package/workflows/backend-feature.json +59 -0
- package/workflows/content-unit.json +19 -0
- package/workflows/frontend-new-surface.json +81 -0
- package/workflows/frontend-reconstruct.json +67 -0
- package/workflows/frontend-refine.json +67 -0
- package/workflows/frontend-with-uat.json +78 -0
- package/workflows/full-feature.json +104 -0
- package/workflows/release.json +29 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import {
|
|
6
|
+
createReadStream,
|
|
7
|
+
createWriteStream,
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
openSync,
|
|
11
|
+
closeSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
readSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
statSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
writeSync
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { finished } from 'node:stream/promises';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const scriptRoot = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const defaultSource = resolve(scriptRoot, '..', '..');
|
|
25
|
+
const manifestName = 'device-state.manifest.json';
|
|
26
|
+
|
|
27
|
+
function fail(message) {
|
|
28
|
+
throw new Error(message);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function slash(value) {
|
|
32
|
+
return value.replaceAll('\\', '/');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function expandHome(value) {
|
|
36
|
+
if (!value.startsWith('~/') && !value.startsWith('~\\')) return value;
|
|
37
|
+
const home = process.env.USERPROFILE ?? process.env.HOME;
|
|
38
|
+
if (!home) fail('Cannot expand ~ because neither USERPROFILE nor HOME is set');
|
|
39
|
+
return join(home, value.slice(2));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function command(name, args, options = {}) {
|
|
43
|
+
const result = spawnSync(name, args, {
|
|
44
|
+
cwd: options.cwd,
|
|
45
|
+
env: options.env ?? process.env,
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
stdio: options.stdio ?? 'pipe'
|
|
49
|
+
});
|
|
50
|
+
if (result.status !== 0) {
|
|
51
|
+
const detail = [result.stderr, result.stdout].filter(Boolean).join('\n').trim();
|
|
52
|
+
fail(`${name} ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`);
|
|
53
|
+
}
|
|
54
|
+
return (result.stdout ?? '').trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function git(source, args) {
|
|
58
|
+
return command('git', ['-C', source, ...args]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sha256Buffer(value) {
|
|
62
|
+
return createHash('sha256').update(value).digest('hex');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function sha256File(path) {
|
|
66
|
+
const hash = createHash('sha256');
|
|
67
|
+
const stream = createReadStream(path);
|
|
68
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
69
|
+
await finished(stream);
|
|
70
|
+
return hash.digest('hex');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readConfig(sourceRoot) {
|
|
74
|
+
const path = join(sourceRoot, '.workspaces', 'device-state.json');
|
|
75
|
+
if (!existsSync(path)) fail(`Missing portable device-state contract: ${path}`);
|
|
76
|
+
const config = JSON.parse(readFileSync(path, 'utf8'));
|
|
77
|
+
if (config.schemaVersion !== 1 || typeof config.id !== 'string') fail('device-state contract must use schemaVersion 1 and a stable id');
|
|
78
|
+
if (!config.docker || typeof config.docker.helperImage !== 'string' || !Array.isArray(config.docker.volumes) || config.docker.volumes.length === 0) {
|
|
79
|
+
fail('device-state contract must declare docker.helperImage and at least one volume');
|
|
80
|
+
}
|
|
81
|
+
const names = new Set();
|
|
82
|
+
for (const volume of config.docker.volumes) {
|
|
83
|
+
if (!volume || typeof volume.name !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(volume.name)) fail('Every device-state volume requires a safe exact name');
|
|
84
|
+
if (names.has(volume.name)) fail(`Duplicate device-state volume: ${volume.name}`);
|
|
85
|
+
names.add(volume.name);
|
|
86
|
+
}
|
|
87
|
+
const remote = config.remote;
|
|
88
|
+
if (!remote || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(remote.repository ?? '')) fail('remote.repository must be owner/repository');
|
|
89
|
+
if (!Number.isInteger(remote.chunkBytes) || remote.chunkBytes < 1024 * 1024 || remote.chunkBytes > 128 * 1024 * 1024) fail('remote.chunkBytes must be between 1 MiB and 128 MiB');
|
|
90
|
+
if (!Number.isInteger(remote.retention) || remote.retention < 1 || remote.retention > 20) fail('remote.retention must be between 1 and 20');
|
|
91
|
+
return { config, path };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function configFingerprint(config) {
|
|
95
|
+
return sha256Buffer(Buffer.from(JSON.stringify(config)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function dockerAvailable() {
|
|
99
|
+
const probe = spawnSync('docker', ['version', '--format', '{{.Server.Version}}'], { encoding: 'utf8', windowsHide: true });
|
|
100
|
+
return probe.status === 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function volumeExists(name) {
|
|
104
|
+
return spawnSync('docker', ['volume', 'inspect', name], { encoding: 'utf8', windowsHide: true }).status === 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function containersForVolume(name, runningOnly = true) {
|
|
108
|
+
const args = ['ps'];
|
|
109
|
+
if (!runningOnly) args.push('--all');
|
|
110
|
+
args.push('--filter', `volume=${name}`, '--format', '{{.Names}}');
|
|
111
|
+
return command('docker', args).split(/\r?\n/).filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function volumeIsEmpty(name, helperImage) {
|
|
115
|
+
if (!volumeExists(name)) return true;
|
|
116
|
+
const result = spawnSync('docker', [
|
|
117
|
+
'run', '--rm', '--volume', `${name}:/target:ro`, helperImage, 'sh', '-c',
|
|
118
|
+
'test -z "$(find /target -mindepth 1 -maxdepth 1 -print -quit)"'
|
|
119
|
+
], { encoding: 'utf8', windowsHide: true });
|
|
120
|
+
return result.status === 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function publicRecipient(identityPath) {
|
|
124
|
+
if (!existsSync(identityPath)) fail(`Master identity is missing: ${identityPath}`);
|
|
125
|
+
const recipient = command('age-keygen', ['-y', identityPath]);
|
|
126
|
+
if (!recipient.startsWith('age1')) fail('age-keygen did not return a valid public recipient');
|
|
127
|
+
return recipient;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function waitFor(processHandle, label) {
|
|
131
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
132
|
+
let stderr = '';
|
|
133
|
+
processHandle.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
134
|
+
processHandle.once('error', rejectPromise);
|
|
135
|
+
processHandle.once('close', (code) => {
|
|
136
|
+
if (code === 0) resolvePromise();
|
|
137
|
+
else rejectPromise(new Error(`${label} failed${stderr.trim() ? `: ${stderr.trim()}` : ''}`));
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function archiveVolume({ volume, helperImage, recipient, output }) {
|
|
143
|
+
mkdirSync(dirname(output), { recursive: true });
|
|
144
|
+
const docker = spawn('docker', [
|
|
145
|
+
'run', '--rm', '--volume', `${volume}:/source:ro`, helperImage,
|
|
146
|
+
'sh', '-c', 'tar -C /source -czf - .'
|
|
147
|
+
], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
148
|
+
const age = spawn('age', ['-r', recipient, '-o', output], { windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
149
|
+
docker.stdout.pipe(age.stdin);
|
|
150
|
+
await Promise.all([waitFor(docker, `archive ${volume}`), waitFor(age, `encrypt ${volume}`)]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function restoreVolume({ volume, helperImage, identityPath, archive, replace }) {
|
|
154
|
+
if (!volumeExists(volume)) command('docker', ['volume', 'create', volume]);
|
|
155
|
+
if (!replace && !volumeIsEmpty(volume, helperImage)) fail(`Refusing to overwrite non-empty volume ${volume}; pass --replace after explicit approval`);
|
|
156
|
+
const cleanup = replace
|
|
157
|
+
? 'find /target -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && tar -C /target -xzf -'
|
|
158
|
+
: 'tar -C /target -xzf -';
|
|
159
|
+
const age = spawn('age', ['-d', '-i', identityPath, archive], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
160
|
+
const docker = spawn('docker', [
|
|
161
|
+
'run', '--rm', '--interactive', '--volume', `${volume}:/target`, helperImage,
|
|
162
|
+
'sh', '-c', cleanup
|
|
163
|
+
], { windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
164
|
+
age.stdout.pipe(docker.stdin);
|
|
165
|
+
await Promise.all([waitFor(age, `decrypt ${volume}`), waitFor(docker, `restore ${volume}`)]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function stopContainers(names) {
|
|
169
|
+
if (names.length > 0) command('docker', ['stop', ...names]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function startContainers(names) {
|
|
173
|
+
if (names.length > 0) command('docker', ['start', ...names]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function splitArchive(path, chunkBytes) {
|
|
177
|
+
const parts = [];
|
|
178
|
+
const input = openSync(path, 'r');
|
|
179
|
+
try {
|
|
180
|
+
const buffer = Buffer.allocUnsafe(chunkBytes);
|
|
181
|
+
let index = 0;
|
|
182
|
+
while (true) {
|
|
183
|
+
const bytes = readSync(input, buffer, 0, chunkBytes, null);
|
|
184
|
+
if (bytes === 0) break;
|
|
185
|
+
const partPath = `${path}.part-${String(index).padStart(4, '0')}`;
|
|
186
|
+
const output = openSync(partPath, 'w');
|
|
187
|
+
try { writeSync(output, buffer, 0, bytes); } finally { closeSync(output); }
|
|
188
|
+
parts.push(partPath);
|
|
189
|
+
index += 1;
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
closeSync(input);
|
|
193
|
+
}
|
|
194
|
+
return parts;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function joinParts(parts, output) {
|
|
198
|
+
const target = createWriteStream(output, { flags: 'w' });
|
|
199
|
+
for (const part of parts) {
|
|
200
|
+
const source = createReadStream(part);
|
|
201
|
+
for await (const chunk of source) {
|
|
202
|
+
if (!target.write(chunk)) await new Promise((resolvePromise) => target.once('drain', resolvePromise));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
target.end();
|
|
206
|
+
await finished(target);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function token(config, sourceRoot) {
|
|
210
|
+
const path = resolve(sourceRoot, config.remote.tokenFile);
|
|
211
|
+
if (!existsSync(path)) fail(`Encrypted runtime token is not hydrated: ${path}; run npm run sync first`);
|
|
212
|
+
const value = readFileSync(path, 'utf8').trim();
|
|
213
|
+
if (!value) fail('Remote checkpoint token file is empty');
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function githubJson(url, tokenValue, options = {}) {
|
|
218
|
+
const response = await fetch(url, {
|
|
219
|
+
...options,
|
|
220
|
+
headers: {
|
|
221
|
+
Accept: 'application/vnd.github+json',
|
|
222
|
+
Authorization: `Bearer ${tokenValue}`,
|
|
223
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
224
|
+
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
|
225
|
+
...(options.headers ?? {})
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
if (!response.ok) fail(`GitHub ${options.method ?? 'GET'} ${url} failed: ${response.status} ${await response.text()}`);
|
|
229
|
+
if (response.status === 204) return null;
|
|
230
|
+
return response.json();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function uploadAsset(repository, releaseId, path, name, tokenValue) {
|
|
234
|
+
const data = readFileSync(path);
|
|
235
|
+
const url = `https://uploads.github.com/repos/${repository}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`;
|
|
236
|
+
const response = await fetch(url, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: {
|
|
239
|
+
Accept: 'application/vnd.github+json',
|
|
240
|
+
Authorization: `Bearer ${tokenValue}`,
|
|
241
|
+
'Content-Type': 'application/octet-stream',
|
|
242
|
+
'Content-Length': String(data.length),
|
|
243
|
+
'X-GitHub-Api-Version': '2022-11-28'
|
|
244
|
+
},
|
|
245
|
+
body: data
|
|
246
|
+
});
|
|
247
|
+
if (!response.ok) fail(`GitHub asset upload ${name} failed: ${response.status} ${await response.text()}`);
|
|
248
|
+
return response.json();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function downloadAsset(asset, path, tokenValue) {
|
|
252
|
+
const response = await fetch(asset.url, {
|
|
253
|
+
headers: {
|
|
254
|
+
Accept: 'application/octet-stream',
|
|
255
|
+
Authorization: `Bearer ${tokenValue}`,
|
|
256
|
+
'X-GitHub-Api-Version': '2022-11-28'
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
if (!response.ok) fail(`GitHub asset download ${asset.name} failed: ${response.status} ${await response.text()}`);
|
|
260
|
+
const target = createWriteStream(path);
|
|
261
|
+
for await (const chunk of response.body) {
|
|
262
|
+
if (!target.write(chunk)) await new Promise((resolvePromise) => target.once('drain', resolvePromise));
|
|
263
|
+
}
|
|
264
|
+
target.end();
|
|
265
|
+
await finished(target);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function releases(config, tokenValue) {
|
|
269
|
+
const values = await githubJson(`https://api.github.com/repos/${config.remote.repository}/releases?per_page=100`, tokenValue);
|
|
270
|
+
return values.filter((release) => !release.draft && release.tag_name.startsWith(`${config.remote.releasePrefix}-`));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function latestRelease(config, tokenValue) {
|
|
274
|
+
const values = await releases(config, tokenValue);
|
|
275
|
+
values.sort((left, right) => new Date(right.published_at) - new Date(left.published_at));
|
|
276
|
+
return values[0] ?? null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function sourceIdentity(sourceRoot) {
|
|
280
|
+
const status = git(sourceRoot, ['status', '--porcelain', '--untracked-files=normal']);
|
|
281
|
+
if (status) fail('Source working tree is not clean; checkpoint only after mission-owned changes are committed');
|
|
282
|
+
const head = git(sourceRoot, ['rev-parse', 'HEAD']);
|
|
283
|
+
const branch = git(sourceRoot, ['branch', '--show-current']);
|
|
284
|
+
const origin = git(sourceRoot, ['remote', 'get-url', 'origin']);
|
|
285
|
+
const upstream = git(sourceRoot, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
|
|
286
|
+
const [behind, ahead] = git(sourceRoot, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`]).split(/\s+/).map(Number);
|
|
287
|
+
if (behind !== 0 || ahead !== 0) fail(`Source must be pushed and remote-current before data publication (behind=${behind}, ahead=${ahead})`);
|
|
288
|
+
return { origin, branch, head };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function generationId(head) {
|
|
292
|
+
return `${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${head.slice(0, 12)}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function buildCheckpoint({ sourceRoot = defaultSource } = {}) {
|
|
296
|
+
if (!dockerAvailable()) fail('Docker daemon is unavailable');
|
|
297
|
+
const source = resolve(sourceRoot);
|
|
298
|
+
const { config } = readConfig(source);
|
|
299
|
+
const identityPath = expandHome(config.encryption.masterIdentity);
|
|
300
|
+
const recipient = publicRecipient(identityPath);
|
|
301
|
+
const sourceState = sourceIdentity(source);
|
|
302
|
+
const generation = generationId(sourceState.head);
|
|
303
|
+
const outputRoot = join(source, '.workspace', 'device-state', 'outgoing', generation);
|
|
304
|
+
mkdirSync(outputRoot, { recursive: true });
|
|
305
|
+
const absent = config.docker.volumes.map((item) => item.name).filter((name) => !volumeExists(name));
|
|
306
|
+
if (absent.length > 0) fail(`Declared volumes are absent: ${absent.join(', ')}`);
|
|
307
|
+
const running = [...new Set(config.docker.volumes.flatMap((item) => containersForVolume(item.name, true)))];
|
|
308
|
+
const artifacts = [];
|
|
309
|
+
stopContainers(running);
|
|
310
|
+
try {
|
|
311
|
+
for (const item of config.docker.volumes) {
|
|
312
|
+
const archive = join(outputRoot, `${item.name}.tar.gz.age`);
|
|
313
|
+
await archiveVolume({ volume: item.name, helperImage: config.docker.helperImage, recipient, output: archive });
|
|
314
|
+
const parts = splitArchive(archive, config.remote.chunkBytes);
|
|
315
|
+
artifacts.push({
|
|
316
|
+
volume: item.name,
|
|
317
|
+
archive: slash(archive),
|
|
318
|
+
ciphertextSha256: await sha256File(archive),
|
|
319
|
+
ciphertextBytes: statSync(archive).size,
|
|
320
|
+
chunks: await Promise.all(parts.map(async (path) => ({
|
|
321
|
+
name: path.split(/[\\/]/).at(-1),
|
|
322
|
+
path: slash(path),
|
|
323
|
+
sha256: await sha256File(path),
|
|
324
|
+
bytes: statSync(path).size
|
|
325
|
+
})))
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
} finally {
|
|
329
|
+
startContainers(running);
|
|
330
|
+
}
|
|
331
|
+
const manifest = {
|
|
332
|
+
schemaVersion: 1,
|
|
333
|
+
contractId: config.id,
|
|
334
|
+
contractSha256: configFingerprint(config),
|
|
335
|
+
generation,
|
|
336
|
+
createdAt: new Date().toISOString(),
|
|
337
|
+
source: sourceState,
|
|
338
|
+
encryption: { format: 'age', recipientSha256: sha256Buffer(Buffer.from(recipient)) },
|
|
339
|
+
archive: { format: 'tar+gzip', quiescedContainers: running },
|
|
340
|
+
artifacts: artifacts.map(({ archive: _archive, ...item }) => ({ ...item, chunks: item.chunks.map(({ path: _path, ...part }) => part) }))
|
|
341
|
+
};
|
|
342
|
+
const manifestPath = join(outputRoot, manifestName);
|
|
343
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
344
|
+
return { config, manifest, manifestPath, outputRoot, artifacts };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function publishCheckpoint({ sourceRoot = defaultSource } = {}) {
|
|
348
|
+
const source = resolve(sourceRoot);
|
|
349
|
+
const built = await buildCheckpoint({ sourceRoot: source });
|
|
350
|
+
const tokenValue = token(built.config, source);
|
|
351
|
+
const tag = `${built.config.remote.releasePrefix}-${built.manifest.generation}`;
|
|
352
|
+
const release = await githubJson(`https://api.github.com/repos/${built.config.remote.repository}/releases`, tokenValue, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
body: JSON.stringify({
|
|
355
|
+
tag_name: tag,
|
|
356
|
+
name: tag,
|
|
357
|
+
body: `Encrypted device-state checkpoint ${built.manifest.generation}\nManifest sha256: ${await sha256File(built.manifestPath)}`,
|
|
358
|
+
draft: true,
|
|
359
|
+
prerelease: false
|
|
360
|
+
})
|
|
361
|
+
});
|
|
362
|
+
try {
|
|
363
|
+
for (const artifact of built.artifacts) {
|
|
364
|
+
for (const part of artifact.chunks) await uploadAsset(built.config.remote.repository, release.id, part.path, part.name, tokenValue);
|
|
365
|
+
}
|
|
366
|
+
await uploadAsset(built.config.remote.repository, release.id, built.manifestPath, manifestName, tokenValue);
|
|
367
|
+
const published = await githubJson(`https://api.github.com/repos/${built.config.remote.repository}/releases/${release.id}`, tokenValue, {
|
|
368
|
+
method: 'PATCH',
|
|
369
|
+
body: JSON.stringify({ draft: false })
|
|
370
|
+
});
|
|
371
|
+
const prior = (await releases(built.config, tokenValue)).filter((item) => item.id !== published.id)
|
|
372
|
+
.sort((left, right) => new Date(right.published_at) - new Date(left.published_at));
|
|
373
|
+
for (const stale of prior.slice(Math.max(0, built.config.remote.retention - 1))) {
|
|
374
|
+
await githubJson(`https://api.github.com/repos/${built.config.remote.repository}/releases/${stale.id}`, tokenValue, { method: 'DELETE' });
|
|
375
|
+
}
|
|
376
|
+
return { status: 'published', generation: built.manifest.generation, releaseUrl: published.html_url, volumeCount: built.manifest.artifacts.length };
|
|
377
|
+
} catch (error) {
|
|
378
|
+
throw new Error(`Checkpoint upload remains a private draft and was not published: ${error.message}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function fetchCheckpoint({ sourceRoot = defaultSource } = {}) {
|
|
383
|
+
const source = resolve(sourceRoot);
|
|
384
|
+
const { config } = readConfig(source);
|
|
385
|
+
const tokenValue = token(config, source);
|
|
386
|
+
const release = await latestRelease(config, tokenValue);
|
|
387
|
+
if (!release) return { status: 'absent', generation: null, directory: null };
|
|
388
|
+
const manifestAsset = release.assets.find((asset) => asset.name === manifestName);
|
|
389
|
+
if (!manifestAsset) fail(`Release ${release.tag_name} has no ${manifestName}`);
|
|
390
|
+
const incomingRoot = join(source, '.workspace', 'device-state', 'incoming', release.tag_name);
|
|
391
|
+
mkdirSync(incomingRoot, { recursive: true });
|
|
392
|
+
const manifestPath = join(incomingRoot, manifestName);
|
|
393
|
+
await downloadAsset(manifestAsset, manifestPath, tokenValue);
|
|
394
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
395
|
+
if (manifest.schemaVersion !== 1 || manifest.contractId !== config.id || manifest.contractSha256 !== configFingerprint(config)) {
|
|
396
|
+
fail('Remote checkpoint manifest is incompatible with the current portable contract');
|
|
397
|
+
}
|
|
398
|
+
for (const artifact of manifest.artifacts) {
|
|
399
|
+
const partPaths = [];
|
|
400
|
+
for (const chunk of artifact.chunks) {
|
|
401
|
+
const asset = release.assets.find((item) => item.name === chunk.name);
|
|
402
|
+
if (!asset) fail(`Release is missing chunk ${chunk.name}`);
|
|
403
|
+
const path = join(incomingRoot, chunk.name);
|
|
404
|
+
await downloadAsset(asset, path, tokenValue);
|
|
405
|
+
if (await sha256File(path) !== chunk.sha256) fail(`Chunk checksum mismatch: ${chunk.name}`);
|
|
406
|
+
partPaths.push(path);
|
|
407
|
+
}
|
|
408
|
+
const archive = join(incomingRoot, `${artifact.volume}.tar.gz.age`);
|
|
409
|
+
await joinParts(partPaths, archive);
|
|
410
|
+
if (await sha256File(archive) !== artifact.ciphertextSha256) fail(`Archive checksum mismatch: ${artifact.volume}`);
|
|
411
|
+
}
|
|
412
|
+
return { status: 'fetched', generation: manifest.generation, directory: incomingRoot, manifest };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export async function restoreCheckpoint({ sourceRoot = defaultSource, replace = false } = {}) {
|
|
416
|
+
if (!dockerAvailable()) fail('Docker daemon is unavailable');
|
|
417
|
+
const source = resolve(sourceRoot);
|
|
418
|
+
const { config } = readConfig(source);
|
|
419
|
+
const fetched = await fetchCheckpoint({ sourceRoot: source });
|
|
420
|
+
if (fetched.status === 'absent') fail('No published device-state checkpoint exists');
|
|
421
|
+
const identityPath = expandHome(config.encryption.masterIdentity);
|
|
422
|
+
const volumeNames = fetched.manifest.artifacts.map((item) => item.volume);
|
|
423
|
+
const running = [...new Set(volumeNames.flatMap((name) => containersForVolume(name, true)))];
|
|
424
|
+
stopContainers(running);
|
|
425
|
+
try {
|
|
426
|
+
for (const artifact of fetched.manifest.artifacts) {
|
|
427
|
+
await restoreVolume({
|
|
428
|
+
volume: artifact.volume,
|
|
429
|
+
helperImage: config.docker.helperImage,
|
|
430
|
+
identityPath,
|
|
431
|
+
archive: join(fetched.directory, `${artifact.volume}.tar.gz.age`),
|
|
432
|
+
replace
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
} finally {
|
|
436
|
+
startContainers(running);
|
|
437
|
+
}
|
|
438
|
+
return { status: 'restored', generation: fetched.generation, volumeCount: volumeNames.length, replaced: replace };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export async function checkpointStatus({ sourceRoot = defaultSource } = {}) {
|
|
442
|
+
const source = resolve(sourceRoot);
|
|
443
|
+
const { config } = readConfig(source);
|
|
444
|
+
const local = config.docker.volumes.map((item) => ({
|
|
445
|
+
volume: item.name,
|
|
446
|
+
exists: dockerAvailable() && volumeExists(item.name),
|
|
447
|
+
empty: dockerAvailable() ? volumeIsEmpty(item.name, config.docker.helperImage) : null
|
|
448
|
+
}));
|
|
449
|
+
let remote = null;
|
|
450
|
+
const tokenPath = resolve(source, config.remote.tokenFile);
|
|
451
|
+
if (existsSync(tokenPath)) {
|
|
452
|
+
const release = await latestRelease(config, token(config, source));
|
|
453
|
+
remote = release ? { tag: release.tag_name, publishedAt: release.published_at, url: release.html_url } : null;
|
|
454
|
+
}
|
|
455
|
+
return { status: 'ready', contractId: config.id, local, remote };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function parse(argv) {
|
|
459
|
+
const commandName = argv[0];
|
|
460
|
+
const sourceIndex = argv.indexOf('--source');
|
|
461
|
+
const sourceRoot = sourceIndex < 0 ? defaultSource : argv[sourceIndex + 1];
|
|
462
|
+
if (!sourceRoot) fail('--source requires a value');
|
|
463
|
+
return {
|
|
464
|
+
commandName,
|
|
465
|
+
sourceRoot,
|
|
466
|
+
apply: argv.includes('--apply'),
|
|
467
|
+
replace: argv.includes('--replace')
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function run(argv = process.argv.slice(2)) {
|
|
472
|
+
const options = parse(argv);
|
|
473
|
+
if (!['plan', 'status', 'publish', 'fetch', 'restore'].includes(options.commandName)) {
|
|
474
|
+
fail('Usage: device-state.mjs <plan|status|publish|fetch|restore> --source <Source> [--apply] [--replace]');
|
|
475
|
+
}
|
|
476
|
+
if (['publish', 'fetch', 'restore'].includes(options.commandName) && !options.apply) {
|
|
477
|
+
fail(`${options.commandName} requires --apply because it mutates local or external state`);
|
|
478
|
+
}
|
|
479
|
+
if (options.replace && options.commandName !== 'restore') fail('--replace is valid only with restore');
|
|
480
|
+
if (options.commandName === 'plan') {
|
|
481
|
+
const { config, path } = readConfig(resolve(options.sourceRoot));
|
|
482
|
+
return { status: 'planned', contractPath: slash(path), volumeCount: config.docker.volumes.length, volumes: config.docker.volumes.map((item) => item.name), remote: config.remote.repository };
|
|
483
|
+
}
|
|
484
|
+
if (options.commandName === 'status') return checkpointStatus(options);
|
|
485
|
+
if (options.commandName === 'publish') return publishCheckpoint(options);
|
|
486
|
+
if (options.commandName === 'fetch') return fetchCheckpoint(options);
|
|
487
|
+
return restoreCheckpoint(options);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
491
|
+
try {
|
|
492
|
+
console.log(JSON.stringify(await run(), null, 2));
|
|
493
|
+
} catch (error) {
|
|
494
|
+
console.error(error.message);
|
|
495
|
+
process.exitCode = 2;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { joinParts, splitArchive } from './device-state.mjs';
|
|
7
|
+
|
|
8
|
+
test('chunks and rejoins encrypted checkpoint bytes without drift', async () => {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'starci-device-state-'));
|
|
10
|
+
const source = join(root, 'archive.age');
|
|
11
|
+
const output = join(root, 'joined.age');
|
|
12
|
+
const bytes = Buffer.from('device-state-checkpoint\n'.repeat(4096));
|
|
13
|
+
writeFileSync(source, bytes);
|
|
14
|
+
const parts = splitArchive(source, 4096);
|
|
15
|
+
assert.ok(parts.length > 1);
|
|
16
|
+
await joinParts(parts, output);
|
|
17
|
+
assert.deepEqual(readFileSync(output), bytes);
|
|
18
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// One registry of stop codes from two homes: operators/errors.json (codes shared by several operators,
|
|
2
|
+
// each with a `scope` list or ["*"]) and operators/<id>/errors.json (codes only that operator emits,
|
|
3
|
+
// scope implicit). Merging here is what lets a code live next to its only operator without the
|
|
4
|
+
// validators, the generator, or the runtime having to know where it came from. A code defined in
|
|
5
|
+
// two places is refused: the fix is to move it to operators/errors.json with both ids in scope.
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const DISPOSITIONS = new Set(['terminate', 'fallback']);
|
|
11
|
+
|
|
12
|
+
// The domains a code may hand to are exactly the domains routing.json routes, plus `self` (the
|
|
13
|
+
// emitting operator's own domain, answered with kind resume). Deriving the set from routing.json keeps
|
|
14
|
+
// a code's true owner (identity, contract, curriculum, …) instead of forcing it into a smaller list.
|
|
15
|
+
export async function loadDomains(root) {
|
|
16
|
+
const routing = JSON.parse(await readFile(path.join(root, 'routing.json'), 'utf8'));
|
|
17
|
+
const domains = new Set(['self']);
|
|
18
|
+
for (const table of Object.values(routing.routes ?? {})) for (const d of Object.keys(table)) domains.add(d);
|
|
19
|
+
return domains;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function loadErrorsRegistry(root) {
|
|
23
|
+
const errors = [];
|
|
24
|
+
const codes = {};
|
|
25
|
+
const DOMAINS = await loadDomains(root);
|
|
26
|
+
const define = (id, entry, scope, home) => {
|
|
27
|
+
if (codes[id]) { errors.push(`${home}: ${id} is already defined in ${codes[id].home}; a code two operators emit belongs in operators/errors.json with both ids in scope`); return; }
|
|
28
|
+
if (!/^[A-Z][A-Z0-9_]+$/.test(id)) errors.push(`${home}: code ${id} must be UPPER_SNAKE`);
|
|
29
|
+
if (!DISPOSITIONS.has(entry.disposition)) errors.push(`${home}: ${id} disposition must be terminate or fallback`);
|
|
30
|
+
if (!DOMAINS.has(entry.domain)) errors.push(`${home}: ${id} domain ${entry.domain} is not a routing domain`);
|
|
31
|
+
for (const key of ['meaning', 'resume']) if (!entry[key]?.en || !entry[key]?.vi) errors.push(`${home}: ${id} needs ${key}.en and ${key}.vi`);
|
|
32
|
+
if (entry.disposition === 'fallback' && (!entry.fallback?.en || !entry.fallback?.vi)) errors.push(`${home}: ${id} is a fallback and needs fallback.en and fallback.vi`);
|
|
33
|
+
if (entry.disposition === 'terminate' && entry.fallback) errors.push(`${home}: ${id} terminates and may not carry a fallback`);
|
|
34
|
+
if (entry.unless) {
|
|
35
|
+
const u = entry.unless;
|
|
36
|
+
if (typeof u.param !== 'string' || u.equals === undefined || !DISPOSITIONS.has(u.then)) errors.push(`${home}: ${id} unless needs param, equals, then`);
|
|
37
|
+
if (u.then === entry.disposition) errors.push(`${home}: ${id} unless.then equals its own disposition`);
|
|
38
|
+
}
|
|
39
|
+
codes[id] = { ...entry, scope, home };
|
|
40
|
+
};
|
|
41
|
+
const sharedPath = path.join(root, 'operators', 'errors.json');
|
|
42
|
+
const shared = JSON.parse(await readFile(sharedPath, 'utf8'));
|
|
43
|
+
if (shared.schemaVersion !== 9) errors.push('operators/errors.json: schemaVersion must be 9');
|
|
44
|
+
for (const [id, entry] of Object.entries(shared.codes ?? {})) {
|
|
45
|
+
if (!Array.isArray(entry.scope) || entry.scope.length === 0) { errors.push(`operators/errors.json: ${id} needs a scope list`); continue; }
|
|
46
|
+
if (entry.scope.length === 1 && entry.scope[0] !== '*') errors.push(`operators/errors.json: ${id} is scoped to one operator ${entry.scope[0]}; move it to operators/<id>/errors.json`);
|
|
47
|
+
define(id, entry, entry.scope, 'operators/errors.json');
|
|
48
|
+
}
|
|
49
|
+
const opsDir = path.join(root, 'operators');
|
|
50
|
+
const operatorIds = new Set();
|
|
51
|
+
for (const e of await readdir(opsDir, { withFileTypes: true })) {
|
|
52
|
+
if (!e.isDirectory()) continue;
|
|
53
|
+
const manifest = JSON.parse(await readFile(path.join(opsDir, e.name, 'operator.json'), 'utf8'));
|
|
54
|
+
operatorIds.add(manifest.id);
|
|
55
|
+
const local = path.join(opsDir, e.name, 'errors.json');
|
|
56
|
+
if (!existsSync(local)) continue;
|
|
57
|
+
const rel = `operators/${e.name}/errors.json`;
|
|
58
|
+
const doc = JSON.parse(await readFile(local, 'utf8'));
|
|
59
|
+
if (doc.schemaVersion !== 9) errors.push(`${rel}: schemaVersion must be 9`);
|
|
60
|
+
for (const [id, entry] of Object.entries(doc.codes ?? {})) {
|
|
61
|
+
if (entry.scope !== undefined) errors.push(`${rel}: ${id} must not carry scope; the file's operator is its scope`);
|
|
62
|
+
define(id, entry, [manifest.id], rel);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const [id, c] of Object.entries(codes)) for (const s of c.scope) if (s !== '*' && !operatorIds.has(s)) errors.push(`${c.home}: ${id} scope names ${s}, which is not an operator`);
|
|
66
|
+
const allowed = (id, operatorId) => { const c = codes[id]; return Boolean(c) && (c.scope.includes('*') || c.scope.includes(operatorId)); };
|
|
67
|
+
return { codes, errors, allowed, operatorIds };
|
|
68
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// alias/INDEX.md is the human map of every alias an operator may read, grouped by zone, generated
|
|
2
|
+
// from alias/alias.json (the machine registry) and from operator.json (who binds what). `--check`
|
|
3
|
+
// runs inside npm test so the map cannot drift from the registry. alias.json stays the authority;
|
|
4
|
+
// INDEX.md is how a person, or an agent primed with prose, reads it.
|
|
5
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { loadAliasRegistry } from './alias-registry.mjs';
|
|
10
|
+
|
|
11
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const reg = await loadAliasRegistry(root);
|
|
13
|
+
const ZONE_ORDER = ['workspaces', 'grammar', 'knowledge', 'worktrees', 'remote', 'dynamic', 'tools'];
|
|
14
|
+
|
|
15
|
+
// Who binds each alias, from every operator.json.
|
|
16
|
+
const users = new Map();
|
|
17
|
+
const opsDir = path.join(root, 'operators');
|
|
18
|
+
for (const e of await readdir(opsDir, { withFileTypes: true })) {
|
|
19
|
+
if (!e.isDirectory()) continue;
|
|
20
|
+
const m = JSON.parse(await readFile(path.join(opsDir, e.name, 'operator.json'), 'utf8'));
|
|
21
|
+
for (const r of m.refs ?? []) {
|
|
22
|
+
const base = Object.keys(reg.aliases).filter((k) => r.alias === k || r.alias.startsWith(`${k}/`)).sort((a, b) => b.length - a.length)[0];
|
|
23
|
+
if (!base) continue;
|
|
24
|
+
users.set(base, [...(users.get(base) ?? []), `${m.id}${r.required ? '' : ' (optional)'}`]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const esc = (s) => String(s).replace(/\|/g, '\\|');
|
|
28
|
+
const code = (s) => `\`${String(s).replace(/`/g, '')}\``;
|
|
29
|
+
const t = {
|
|
30
|
+
en: { title: '# Alias', intro: 'Every location an operator may read, by alias, grouped by zone. Generated from `alias/alias.json` (the machine registry) and every `operator.json` by `scripts/generate-alias-doc.mjs`; `--check` runs inside `npm test`. An operator reads only the aliases its own `operator.md` Context table names; this page is the whole vocabulary those tables draw from. Resolution takes the longest registered prefix, a sub-path narrows an alias, and a segment in angle brackets is supplied by the invocation.', zone: 'Zone', head: '| Alias | Params | Resolves to | Bind | Writers | Bound by | Purpose |', seg: '## Friendly segments inside a checkout', segHead: '| Segment | Resolves to |', zones: { workspaces: 'Workspaces — the working area', grammar: 'Grammar — the package', knowledge: 'Knowledge — the law', worktrees: 'Worktrees — local authority and evidence', remote: 'Remote — the internet', dynamic: 'Dynamic — produced inside the session' } },
|
|
31
|
+
vi: { title: '# Alias', intro: 'Mọi nơi một operator được đọc, theo alias, gom theo vùng. Sinh từ `alias/alias.json` (sổ cho máy) và mọi `operator.json` bởi `scripts/generate-alias-doc.mjs`; `--check` chạy trong `npm test`. Operator chỉ đọc những alias mà bảng Context trong `operator.md` của nó gọi tên; trang này là toàn bộ từ vựng mà các bảng đó lấy ra. Phân giải theo tiền tố đăng ký dài nhất, đuôi đường dẫn thu hẹp alias, đoạn trong ngoặc nhọn do lần gọi cung cấp.', zone: 'Vùng', head: '| Alias | Tham số | Trỏ tới | Bind | Ai ghi | Operator ràng | Mục đích |', seg: '## Đoạn thân thiện trong một checkout', segHead: '| Đoạn | Trỏ tới |', zones: { workspaces: 'Workspaces — vùng làm việc', grammar: 'Grammar — gói', knowledge: 'Knowledge — luật', worktrees: 'Worktrees — thẩm quyền và bằng chứng máy-cục-bộ', remote: 'Remote — internet', dynamic: 'Dynamic — sinh trong phiên' } },
|
|
32
|
+
};
|
|
33
|
+
function render(lang) {
|
|
34
|
+
const s = t[lang];
|
|
35
|
+
const out = [s.title, '', s.intro, ''];
|
|
36
|
+
for (const z of ZONE_ORDER) {
|
|
37
|
+
const entries = Object.entries(reg.aliases).filter(([, v]) => v.zone === z).sort(([a], [b]) => a.localeCompare(b));
|
|
38
|
+
if (!entries.length) continue;
|
|
39
|
+
out.push(`## ${s.zones[z]}`, '', reg.zones?.[z]?.[lang] ?? '', '', s.head, '| --- | --- | --- | --- | --- | --- | --- |');
|
|
40
|
+
for (const [alias, v] of entries) {
|
|
41
|
+
const params = v.params.length ? v.params.map((p) => code(`<${p}>`)).join(', ') : '—';
|
|
42
|
+
const writers = v.writers.length ? v.writers.map(code).join(', ') : '—';
|
|
43
|
+
const bound = users.get(alias)?.length ? users.get(alias).map(code).join(', ') : '—';
|
|
44
|
+
out.push(`| ${code(alias)} | ${params} | ${code(v.resolvesTo)} | ${esc(v.bind).replace(/</g, '<').replace(/>/g, '>')} | ${writers} | ${bound} | ${esc(v.purpose)} |`);
|
|
45
|
+
}
|
|
46
|
+
out.push('');
|
|
47
|
+
}
|
|
48
|
+
if (reg.segments) {
|
|
49
|
+
out.push(s.seg, '', reg.segments.note ?? '', '', s.segHead, '| --- | --- |');
|
|
50
|
+
for (const [k, v] of Object.entries(reg.segments)) if (k !== 'note') out.push(`| ${code(k)} | ${code(v)} |`);
|
|
51
|
+
out.push('');
|
|
52
|
+
}
|
|
53
|
+
return `${out.join('\n')}\n`;
|
|
54
|
+
}
|
|
55
|
+
const check = process.argv.includes('--check');
|
|
56
|
+
let drift = 0;
|
|
57
|
+
for (const [rel, lang] of [['alias/INDEX.md', 'en'], ['alias/INDEX.vi.md', 'vi']]) {
|
|
58
|
+
const next = render(lang); const file = path.join(root, rel);
|
|
59
|
+
if (check) {
|
|
60
|
+
let cur = ''; try { cur = (await readFile(file, 'utf8')).replace(/\r\n/g, '\n'); } catch { cur = ''; }
|
|
61
|
+
if (cur !== next) { drift += 1; process.stderr.write(`${rel}: out of date; run node scripts/generate-alias-doc.mjs\n`); }
|
|
62
|
+
} else await writeFile(file, next);
|
|
63
|
+
}
|
|
64
|
+
const unzoned = Object.entries(reg.aliases).filter(([, v]) => !ZONE_ORDER.includes(v.zone)).map(([k]) => k);
|
|
65
|
+
if (unzoned.length) { process.stderr.write(`aliases without a known zone: ${unzoned.join(', ')}\n`); drift += 1; }
|
|
66
|
+
if (drift) process.exitCode = 1; else process.stdout.write(`${check ? 'alias map current' : 'alias map written'}: ${Object.keys(reg.aliases).length} aliases in ${ZONE_ORDER.length} zones\n`);
|