@celilo/cli 2.1.0 → 2.2.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/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/schemas/system_config.json +7 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/backup-rename.test.ts +2 -1
- package/src/cli/cli.test.ts +2 -1
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/firewall-interface-list.test.ts +158 -8
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +22 -2
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +2 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +5 -12
- package/src/cli/commands/module-update.test.ts +87 -4
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/module-upgrade.test.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +54 -2
- package/src/cli/commands/module-verify.test.ts +2 -3
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/notify-config.test.ts +5 -3
- package/src/cli/commands/registry-owner.test.ts +2 -1
- package/src/cli/commands/registry-token.test.ts +2 -1
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor.test.ts +71 -0
- package/src/cli/commands/system-doctor.ts +110 -24
- package/src/cli/commands/system-init-deprecation.test.ts +6 -3
- package/src/cli/commands/system-migrate.test.ts +2 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +6 -4
- package/src/cli/restore-command.test.ts +2 -1
- package/src/cli/restore-migration-failure.test.ts +160 -0
- package/src/config/paths.test.ts +3 -3
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +62 -135
- package/src/db/migrate.ts +17 -12
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +52 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +106 -19
- package/src/hooks/executor.ts +120 -13
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +168 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +39 -5
- package/src/hooks/jail.test.ts +62 -3
- package/src/hooks/jail.ts +67 -8
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +130 -2
- package/src/module/packaging/extract.ts +1 -5
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +235 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +264 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- package/src/services/audit/module-integrity.test.ts +0 -21
- package/src/services/audit/module-integrity.ts +0 -14
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +47 -3
- package/src/services/backup-in-flight-refusal.test.ts +2 -1
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-ensure-flow.test.ts +2 -1
- package/src/services/bus-interview-park.test.ts +2 -1
- package/src/services/bus-interview.ts +37 -14
- package/src/services/bus-secret-flow.test.ts +2 -1
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/config-provenance.ts +4 -0
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +2 -1
- package/src/services/control-plane-bootstrap.test.ts +123 -2
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/cross-module-read.test.ts +2 -1
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +25 -2
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +3 -2
- package/src/services/fleet-key.test.ts +68 -3
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/infrastructure-selector.test.ts +2 -1
- package/src/services/infrastructure-variable-resolver.test.ts +2 -1
- package/src/services/machine-pool.test.ts +2 -1
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +188 -0
- package/src/services/module-deploy.ts +199 -21
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +2 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/restore-from-file.test.ts +23 -2
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/restore-preflight.test.ts +2 -1
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +2 -1
- package/src/services/static-content-converge.test.ts +144 -5
- package/src/services/static-content-converge.ts +87 -17
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/system-state-stage.test.ts +2 -1
- package/src/services/trusted-sources.test.ts +33 -2
- package/src/services/trusted-sources.ts +47 -10
- package/src/services/zone-detector.test.ts +2 -1
- package/src/templates/generator.ts +9 -2
- package/src/test-utils/bus-responder.ts +5 -3
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/variables/context.ts +16 -5
- package/src/module/packaging/generated-plane.test.ts +0 -79
- package/src/module/packaging/generated-plane.ts +0 -134
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: an on_restore hook in the celilo-mgmt staging shape, minus
|
|
3
|
+
* everything the real one owns but the staging itself. Copies the artifact's
|
|
4
|
+
* celilo.db into restore_dir/system/ so the Phase 4 wrapper's
|
|
5
|
+
* applyStagedSystemFiles has something to swap.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cpSync, existsSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { defineHook } from '@celilo/capabilities';
|
|
11
|
+
|
|
12
|
+
export default defineHook({
|
|
13
|
+
hook: 'on_restore',
|
|
14
|
+
requires: [],
|
|
15
|
+
optional: [],
|
|
16
|
+
handler: async (ctx) => {
|
|
17
|
+
const restoreDir = (ctx as unknown as { restore_dir: string }).restore_dir;
|
|
18
|
+
const systemDir = join(restoreDir, 'system');
|
|
19
|
+
mkdirSync(systemDir, { recursive: true });
|
|
20
|
+
const artifactDb = join(restoreDir, 'celilo.db');
|
|
21
|
+
if (existsSync(artifactDb)) {
|
|
22
|
+
cpSync(artifactDb, join(systemDir, 'celilo.db'));
|
|
23
|
+
}
|
|
24
|
+
return { restored_items: 1 };
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { HookStore, HookStoreBackedMap } from '@celilo/capabilities';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory hook-owned-state stores for tests that build a HookContext
|
|
5
|
+
* without a broker. The four methods mutate the record directly; the
|
|
6
|
+
* manifest validation a real store applies lives broker-side
|
|
7
|
+
* (hook-store.ts) and has its own tests.
|
|
8
|
+
*
|
|
9
|
+
* Methods are defined non-enumerably, so the result spreads, serializes and
|
|
10
|
+
* `toEqual`s as the plain record the test wrote — the same shape a real
|
|
11
|
+
* broker-side context carries when executeHookScript frames it.
|
|
12
|
+
*/
|
|
13
|
+
export function configStore(
|
|
14
|
+
values: Record<string, unknown> = {},
|
|
15
|
+
): Record<string, unknown> & HookStore {
|
|
16
|
+
return attach(values);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function secretStore(values: Record<string, string> = {}): HookStoreBackedMap {
|
|
20
|
+
// Downcast: the caller passed Record<string, string>; attach only adds
|
|
21
|
+
// methods and never writes, so the value type is unchanged.
|
|
22
|
+
return attach(values) as HookStoreBackedMap;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function attach(values: Record<string, unknown>): Record<string, unknown> & HookStore {
|
|
26
|
+
const store: HookStore = {
|
|
27
|
+
get: async (name) => values[name] as string | undefined,
|
|
28
|
+
set: async (name, value) => {
|
|
29
|
+
values[name] = value;
|
|
30
|
+
},
|
|
31
|
+
delete: async (name) => {
|
|
32
|
+
delete values[name];
|
|
33
|
+
},
|
|
34
|
+
transaction: async (fn) => {
|
|
35
|
+
await fn(store);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
for (const [name, method] of Object.entries(store)) {
|
|
39
|
+
Object.defineProperty(values, name, {
|
|
40
|
+
value: method,
|
|
41
|
+
enumerable: false,
|
|
42
|
+
writable: true,
|
|
43
|
+
configurable: true,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return Object.assign(values, store);
|
|
47
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: a hook that exercises the hook-owned-state accessor across
|
|
3
|
+
* the process boundary — set/get round trip on both stores, delete, a
|
|
4
|
+
* committed transaction, a discarded transaction, and the undeclared-name
|
|
5
|
+
* throw the whole design exists to produce.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineHook } from '@celilo/capabilities';
|
|
9
|
+
|
|
10
|
+
export default defineHook({
|
|
11
|
+
hook: 'on_install',
|
|
12
|
+
requires: [],
|
|
13
|
+
handler: async (ctx) => {
|
|
14
|
+
const outputs: Record<string, unknown> = {};
|
|
15
|
+
|
|
16
|
+
// Round trip on each store.
|
|
17
|
+
await ctx.secrets.set('bot_token', 'token-value');
|
|
18
|
+
outputs.secretRoundTrip = await ctx.secrets.get('bot_token');
|
|
19
|
+
|
|
20
|
+
await ctx.config.set('public_ip', '203.0.113.7');
|
|
21
|
+
outputs.configRoundTrip = await ctx.config.get('public_ip');
|
|
22
|
+
|
|
23
|
+
// The map surface still answers from the values the context carried.
|
|
24
|
+
outputs.mapRead = ctx.config.mapOnlyValue;
|
|
25
|
+
|
|
26
|
+
// Delete removes; deleting a declared-but-never-written name is a no-op.
|
|
27
|
+
// (An UNDECLARED name on delete throws — that is 3.4, tested below.
|
|
28
|
+
// Declared-and-absent is the no-op case 3.7 pins.)
|
|
29
|
+
await ctx.secrets.delete('bot_token');
|
|
30
|
+
await ctx.secrets.set('api_key', 'to-delete');
|
|
31
|
+
await ctx.secrets.delete('api_key');
|
|
32
|
+
outputs.deletedIsGone = await ctx.secrets.get('api_key');
|
|
33
|
+
|
|
34
|
+
// Committed transaction: both writes land.
|
|
35
|
+
await ctx.secrets.transaction((s) => {
|
|
36
|
+
s.set('bot_token', 'committed-a');
|
|
37
|
+
s.set('api_key', 'committed-b');
|
|
38
|
+
});
|
|
39
|
+
outputs.transactionA = await ctx.secrets.get('bot_token');
|
|
40
|
+
outputs.transactionB = await ctx.secrets.get('api_key');
|
|
41
|
+
|
|
42
|
+
// Discarded transaction: nothing lands, including the overwrite of the
|
|
43
|
+
// committed value above.
|
|
44
|
+
try {
|
|
45
|
+
await ctx.secrets.transaction(async (s) => {
|
|
46
|
+
s.set('bot_token', 'discarded');
|
|
47
|
+
throw new Error('hook failed midway');
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
outputs.discarded = (error as Error).message;
|
|
51
|
+
}
|
|
52
|
+
outputs.afterDiscard = await ctx.secrets.get('bot_token');
|
|
53
|
+
|
|
54
|
+
// An undeclared name throws, naming the module and the declared set.
|
|
55
|
+
try {
|
|
56
|
+
await ctx.secrets.set('not_declared', 'x');
|
|
57
|
+
} catch (error) {
|
|
58
|
+
outputs.undeclaredSecret = (error as Error).message;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return outputs;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
@@ -26,6 +26,7 @@ import { executeHookScript, hookChildEnv } from './executor';
|
|
|
26
26
|
import type { MountSetWire } from './hook-protocol';
|
|
27
27
|
import { createCapturingLogger } from './logger';
|
|
28
28
|
import { deriveMountSet } from './mount-set';
|
|
29
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
29
30
|
import type { HookContext } from './types';
|
|
30
31
|
import { classifyAccess, mountSetEnvValue, parseLintMountSet } from './unjailed-lint';
|
|
31
32
|
|
|
@@ -54,10 +55,25 @@ function scratchModule(script: string): string {
|
|
|
54
55
|
|
|
55
56
|
const describeSet = (overrides: Partial<MountSetWire>): MountSetWire => ({
|
|
56
57
|
entries: [
|
|
57
|
-
{ path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run' },
|
|
58
|
-
{
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
{ path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run', absence: 'runtime' },
|
|
59
|
+
{
|
|
60
|
+
path: '/srv/celilo/mod',
|
|
61
|
+
mode: 'ro',
|
|
62
|
+
reason: "the module's own tree",
|
|
63
|
+
absence: 'required',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
path: '/srv/celilo/mod/state',
|
|
67
|
+
mode: 'rw',
|
|
68
|
+
reason: 'the sanctioned writable directory',
|
|
69
|
+
absence: 'required',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
path: '/srv/staged',
|
|
73
|
+
mode: 'rw',
|
|
74
|
+
reason: "contract input 'backup_dir' (write)",
|
|
75
|
+
absence: 'required',
|
|
76
|
+
},
|
|
61
77
|
],
|
|
62
78
|
chdir: '/srv/celilo/mod',
|
|
63
79
|
...overrides,
|
|
@@ -159,8 +175,8 @@ describe('the lint inside a real hook run', () => {
|
|
|
159
175
|
mkdirSync(join(root, 'state'), { recursive: true });
|
|
160
176
|
const { logger, messages } = createCapturingLogger();
|
|
161
177
|
const context: HookContext = {
|
|
162
|
-
config:
|
|
163
|
-
secrets:
|
|
178
|
+
config: configStore(),
|
|
179
|
+
secrets: secretStore(),
|
|
164
180
|
systems: [],
|
|
165
181
|
logger,
|
|
166
182
|
debug: false,
|
package/src/manifest/schema.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
} from '../../services/module-instances';
|
|
14
14
|
import { computeFileChecksum } from './checksum';
|
|
15
15
|
import type { IntegrityViolation } from './extract';
|
|
16
|
-
import { compareVerbatimRoleAssets, readVerbatimRoleAssets } from './generated-plane';
|
|
17
16
|
import { type HostPlaneResult, verifyModuleOnHosts } from './host-plane';
|
|
18
17
|
import { classifyModulePath } from './package-rules';
|
|
19
18
|
|
|
@@ -204,30 +203,11 @@ export async function auditModule(
|
|
|
204
203
|
}
|
|
205
204
|
}
|
|
206
205
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
const generatedDir = join(moduleDir, 'generated');
|
|
213
|
-
if (existsSync(generatedDir)) {
|
|
214
|
-
const differences = compareVerbatimRoleAssets(
|
|
215
|
-
await readVerbatimRoleAssets(moduleDir),
|
|
216
|
-
await readVerbatimRoleAssets(generatedDir),
|
|
217
|
-
);
|
|
218
|
-
for (const difference of differences) {
|
|
219
|
-
violations.push({
|
|
220
|
-
type: 'stale-generated',
|
|
221
|
-
path: difference.relPath,
|
|
222
|
-
expectedDigest: difference.installedDigest,
|
|
223
|
-
actualDigest: difference.generatedDigest,
|
|
224
|
-
message:
|
|
225
|
-
difference.reason === 'missing'
|
|
226
|
-
? `Generated project is missing ${difference.relPath} — the next deploy would ship nothing for it. Run 'celilo module generate ${moduleId}'.`
|
|
227
|
-
: `Generated project holds different bytes for ${difference.relPath} (generated ${difference.generatedDigest}, installed ${difference.installedDigest}) — the next deploy would ship the wrong ones. Run 'celilo module generate ${moduleId}'.`,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
}
|
|
206
|
+
// The second plane (is what we would deploy built from what we installed?)
|
|
207
|
+
// retired here: the generated tree is ephemeral now (D4 of
|
|
208
|
+
// control-plane-stops-building-modules) — rendered for a deploy and
|
|
209
|
+
// deleted on success — so a persistent generated copy that verbatim role
|
|
210
|
+
// assets could be compared against no longer exists to compare.
|
|
231
211
|
|
|
232
212
|
// Check for extra files (not in checksums)
|
|
233
213
|
const actualFiles = await scanDirectory(moduleDir, moduleDir);
|
|
@@ -244,7 +224,10 @@ export async function auditModule(
|
|
|
244
224
|
}
|
|
245
225
|
|
|
246
226
|
// Plane three: is what is running what we generated? One SSH per system,
|
|
247
|
-
// so it is asked only when the caller says so.
|
|
227
|
+
// so it is asked only when the caller says so. With the generated tree
|
|
228
|
+
// ephemeral (D4), a deep audit after a successful deploy finds no playbook
|
|
229
|
+
// and says so — the remedy is generate, which is cheap and never builds.
|
|
230
|
+
const generatedDir = join(moduleDir, 'generated');
|
|
248
231
|
let hostPlane: HostPlaneResult | undefined;
|
|
249
232
|
if (options.deep) {
|
|
250
233
|
hostPlane = await verifyModuleOnHosts({
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
|
+
import { resolveBuildCommandPaths } from './build-paths';
|
|
7
|
+
|
|
8
|
+
/** Repo root: this file sits at apps/celilo/src/module/packaging/. */
|
|
9
|
+
const REPO_ROOT = join(import.meta.dir, '..', '..', '..', '..', '..');
|
|
10
|
+
|
|
11
|
+
/** Every module in the repo that declares a `build:` block (design, Context). */
|
|
12
|
+
const BUILD_MODULES = [
|
|
13
|
+
'celilo-registry',
|
|
14
|
+
'celilo-web-console',
|
|
15
|
+
'celilo-website',
|
|
16
|
+
'forgejo',
|
|
17
|
+
'npm-cache-node',
|
|
18
|
+
'wireguard-manager',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
function buildCommand(moduleId: string): string {
|
|
22
|
+
const manifestPath = join(REPO_ROOT, 'modules', moduleId, 'manifest.yml');
|
|
23
|
+
if (!existsSync(manifestPath)) throw new Error(`fixture missing: ${manifestPath}`);
|
|
24
|
+
const manifest = parseYaml(readFileSync(manifestPath, 'utf8')) as {
|
|
25
|
+
build?: { command?: string };
|
|
26
|
+
};
|
|
27
|
+
const command = manifest.build?.command;
|
|
28
|
+
if (!command) throw new Error(`${moduleId} declares no build command`);
|
|
29
|
+
return command;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('publish-time build path gate', () => {
|
|
33
|
+
test('flags celilo-registry when packaged outside the monorepo (the control-plane shape)', () => {
|
|
34
|
+
// The known-bad form, per celilo#1307: the script reaches a sibling of the
|
|
35
|
+
// module source (`../../packages/registry-server`). Inside the monorepo
|
|
36
|
+
// that resolves; from a tree where the module stands alone — a control
|
|
37
|
+
// plane, or any packaging that is not the source checkout — it does not.
|
|
38
|
+
// The spec scenario pins this: "packaged from a tree where that directory
|
|
39
|
+
// does not exist".
|
|
40
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
|
|
41
|
+
const moduleSourceDir = join(scratch, 'celilo-registry');
|
|
42
|
+
cpSync(join(REPO_ROOT, 'modules', 'celilo-registry'), moduleSourceDir, {
|
|
43
|
+
recursive: true,
|
|
44
|
+
});
|
|
45
|
+
const violations = resolveBuildCommandPaths(buildCommand('celilo-registry'), {
|
|
46
|
+
moduleSourceDir,
|
|
47
|
+
buildDir: moduleSourceDir,
|
|
48
|
+
});
|
|
49
|
+
expect(violations).toHaveLength(1);
|
|
50
|
+
expect(violations[0].rawPath).toContain('../../packages/registry-server');
|
|
51
|
+
expect(violations[0].resolvedPath).not.toContain(REPO_ROOT);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('flags a plain relative escape that does not resolve (spec scenario)', () => {
|
|
55
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
|
|
56
|
+
const violations = resolveBuildCommandPaths(
|
|
57
|
+
'cd ../../packages/registry-server && bun install',
|
|
58
|
+
{ moduleSourceDir: scratch, buildDir: scratch },
|
|
59
|
+
);
|
|
60
|
+
expect(violations).toHaveLength(1);
|
|
61
|
+
expect(violations[0].rawPath).toBe('../../packages/registry-server');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('accepts $CELILO_MODULE_SOURCE_DIR/server when it exists (npm-cache-node shape)', () => {
|
|
65
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
|
|
66
|
+
const moduleSourceDir = join(scratch, 'npm-cache-node');
|
|
67
|
+
cpSync(
|
|
68
|
+
join(REPO_ROOT, 'modules', 'npm-cache-node', 'server'),
|
|
69
|
+
join(moduleSourceDir, 'server'),
|
|
70
|
+
{
|
|
71
|
+
recursive: true,
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
const violations = resolveBuildCommandPaths(buildCommand('npm-cache-node'), {
|
|
75
|
+
moduleSourceDir,
|
|
76
|
+
buildDir: moduleSourceDir,
|
|
77
|
+
});
|
|
78
|
+
expect(violations).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('accepts every module build command at publish time in the monorepo', () => {
|
|
82
|
+
// The regression surface for the five healthy modules. celilo-registry
|
|
83
|
+
// passes here too, and that is correct: at publish, inside the monorepo,
|
|
84
|
+
// its `cd` target exists (the binaries shipped on 2026-08-20 prove the
|
|
85
|
+
// build ran). The gate refuses the script only where it genuinely cannot
|
|
86
|
+
// resolve — the first test above.
|
|
87
|
+
for (const moduleId of BUILD_MODULES) {
|
|
88
|
+
const moduleSourceDir = join(REPO_ROOT, 'modules', moduleId);
|
|
89
|
+
const violations = resolveBuildCommandPaths(buildCommand(moduleId), {
|
|
90
|
+
moduleSourceDir,
|
|
91
|
+
buildDir: moduleSourceDir,
|
|
92
|
+
});
|
|
93
|
+
expect(violations, moduleId).toEqual([]);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('skips a cd target it cannot resolve rather than guessing (forgejo shape)', () => {
|
|
98
|
+
// `cd "$D"` where D was assigned earlier in the same command: the gate
|
|
99
|
+
// does not interpret shell, so an unresolvable target is skipped, not
|
|
100
|
+
// failed. A guard that rejects working modules is worse than none.
|
|
101
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
|
|
102
|
+
const violations = resolveBuildCommandPaths('D=files && mkdir -p "$D" && cd "$D" && ls', {
|
|
103
|
+
moduleSourceDir: scratch,
|
|
104
|
+
buildDir: scratch,
|
|
105
|
+
});
|
|
106
|
+
expect(violations).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('tracks the working directory across chained cds (celilo-web-console shape)', () => {
|
|
110
|
+
// `cd $SOURCE/../../apps/console` then `cd ../console-server`: the second
|
|
111
|
+
// target resolves against the FIRST cd's destination, not the build dir.
|
|
112
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
|
|
113
|
+
const moduleSourceDir = join(scratch, 'web');
|
|
114
|
+
const monorepo = join(scratch, 'mono');
|
|
115
|
+
cpSync(join(REPO_ROOT, 'modules', 'celilo-web-console'), moduleSourceDir, { recursive: true });
|
|
116
|
+
// A monorepo-shaped sibling tree, minimal but real.
|
|
117
|
+
const { mkdirSync } = require('node:fs') as typeof import('node:fs');
|
|
118
|
+
mkdirSync(join(monorepo, 'apps', 'console'), { recursive: true });
|
|
119
|
+
mkdirSync(join(monorepo, 'apps', 'console-server'), { recursive: true });
|
|
120
|
+
cpSync(moduleSourceDir, join(monorepo, 'modules', 'celilo-web-console'), { recursive: true });
|
|
121
|
+
const violations = resolveBuildCommandPaths(buildCommand('celilo-web-console'), {
|
|
122
|
+
moduleSourceDir: join(monorepo, 'modules', 'celilo-web-console'),
|
|
123
|
+
buildDir: join(monorepo, 'modules', 'celilo-web-console'),
|
|
124
|
+
});
|
|
125
|
+
expect(violations).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Publish-time gate on a module build command's filesystem references.
|
|
6
|
+
*
|
|
7
|
+
* A `build:` block runs when the module is packaged (publish/package/import),
|
|
8
|
+
* inside a staged copy of the module, with `CELILO_MODULE_SOURCE_DIR` pointing
|
|
9
|
+
* back at the authored source tree. A `cd` in that command is the build's
|
|
10
|
+
* claim about where its inputs live; a target that does not resolve there
|
|
11
|
+
* means the module ships inputs it does not carry, which is how celilo-registry
|
|
12
|
+
* was unbuildable on every real install for four months without anyone finding
|
|
13
|
+
* out (celilo#1307). The packager refuses up front and names the path.
|
|
14
|
+
*
|
|
15
|
+
* The gate does not interpret shell. It finds `cd` commands lexically, knows
|
|
16
|
+
* the two variables the packager supplies, and probes what those resolve to.
|
|
17
|
+
* A target it cannot resolve (an untracked shell variable, a command
|
|
18
|
+
* substitution, a glob) is SKIPPED, not failed: guessing would reject working
|
|
19
|
+
* modules, and a guard that rejects working modules is worse than none.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface BuildPathViolation {
|
|
23
|
+
/** The path exactly as the build command wrote it, before substitution. */
|
|
24
|
+
rawPath: string;
|
|
25
|
+
/** The absolute path the packaging runtime would resolve it to. */
|
|
26
|
+
resolvedPath: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResolveBuildCommandPathsOptions {
|
|
30
|
+
/** What `$CELILO_MODULE_SOURCE_DIR` resolves to when the command runs. */
|
|
31
|
+
moduleSourceDir: string;
|
|
32
|
+
/** The staged tree the command runs in (its initial cwd, and `$STAGE`). */
|
|
33
|
+
buildDir: string;
|
|
34
|
+
/** Injectable existence probe; defaults to existsSync. */
|
|
35
|
+
exists?: (path: string) => boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract the argument of every unquoted `cd` command in the string, with the
|
|
40
|
+
* quoting preserved so later substitution can strip it.
|
|
41
|
+
*
|
|
42
|
+
* A `cd` counts only at a command position: string start, or after `&&`, `||`,
|
|
43
|
+
* `;`, `|`, `(`, or a newline, ignoring whitespace. Quoted spans (single and
|
|
44
|
+
* double) are skipped whole, so a `cd` inside a string is not mistaken for a
|
|
45
|
+
* command.
|
|
46
|
+
*/
|
|
47
|
+
export function findCdTargets(command: string): string[] {
|
|
48
|
+
const targets: string[] = [];
|
|
49
|
+
let atCommandStart = true;
|
|
50
|
+
let i = 0;
|
|
51
|
+
while (i < command.length) {
|
|
52
|
+
const ch = command[i];
|
|
53
|
+
if (ch === "'") {
|
|
54
|
+
const end = command.indexOf("'", i + 1);
|
|
55
|
+
i = end === -1 ? command.length : end + 1;
|
|
56
|
+
atCommandStart = false;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === '"') {
|
|
60
|
+
const end = command.indexOf('"', i + 1);
|
|
61
|
+
i = end === -1 ? command.length : end + 1;
|
|
62
|
+
atCommandStart = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
66
|
+
i += 2;
|
|
67
|
+
atCommandStart = false;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (ch === '&' || ch === '|' || ch === ';' || ch === '\n' || ch === '(') {
|
|
71
|
+
atCommandStart = true;
|
|
72
|
+
i += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (/\s/.test(ch)) {
|
|
76
|
+
i += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (atCommandStart && command.startsWith('cd', i) && /\s/.test(command[i + 2] ?? '')) {
|
|
80
|
+
const { target, next } = readCdTarget(command, i + 2);
|
|
81
|
+
if (target.length > 0) targets.push(target);
|
|
82
|
+
i = next;
|
|
83
|
+
atCommandStart = false;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
atCommandStart = false;
|
|
87
|
+
i += 1;
|
|
88
|
+
}
|
|
89
|
+
return targets;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read one `cd` argument starting just after the `cd` keyword. Stops at an
|
|
94
|
+
* unquoted command terminator. Backslash escapes are carried through so the
|
|
95
|
+
* caller sees exactly what the shell would.
|
|
96
|
+
*/
|
|
97
|
+
function readCdTarget(command: string, start: number): { target: string; next: number } {
|
|
98
|
+
let i = start;
|
|
99
|
+
while (i < command.length && /\s/.test(command[i])) i += 1;
|
|
100
|
+
const begin = i;
|
|
101
|
+
let end = i;
|
|
102
|
+
while (i < command.length) {
|
|
103
|
+
const ch = command[i];
|
|
104
|
+
if (ch === "'") {
|
|
105
|
+
const close = command.indexOf("'", i + 1);
|
|
106
|
+
i = close === -1 ? command.length : close + 1;
|
|
107
|
+
end = i;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ch === '"') {
|
|
111
|
+
const close = command.indexOf('"', i + 1);
|
|
112
|
+
i = close === -1 ? command.length : close + 1;
|
|
113
|
+
end = i;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
117
|
+
i += 2;
|
|
118
|
+
end = i;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (ch === '&' || ch === '|' || ch === ';' || ch === '\n') break;
|
|
122
|
+
i += 1;
|
|
123
|
+
if (!/\s/.test(ch) || command.slice(begin, i).trim().length > 0) end = i;
|
|
124
|
+
}
|
|
125
|
+
return { target: command.slice(begin, end).trim(), next: i };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Substitute the packager-supplied variables, strip quoting, and drop the
|
|
130
|
+
* target to `null` when anything unresolvable remains. `null` means "this
|
|
131
|
+
* gate cannot answer whether this path exists", never "it does not exist".
|
|
132
|
+
*/
|
|
133
|
+
function resolveTarget(target: string, options: ResolveBuildCommandPathsOptions): string | null {
|
|
134
|
+
const substituted = target
|
|
135
|
+
.replaceAll('$CELILO_MODULE_SOURCE_DIR', options.moduleSourceDir)
|
|
136
|
+
.replaceAll('$(pwd)', options.buildDir)
|
|
137
|
+
.replaceAll('$STAGE', options.buildDir)
|
|
138
|
+
.replaceAll('"', '')
|
|
139
|
+
.replaceAll("'", '');
|
|
140
|
+
if (/[$`*?~[(]/.test(substituted)) return null;
|
|
141
|
+
if (substituted.startsWith('-')) return null; // `cd -` / flags, not a path
|
|
142
|
+
return substituted;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function resolveBuildCommandPaths(
|
|
146
|
+
command: string,
|
|
147
|
+
options: ResolveBuildCommandPathsOptions,
|
|
148
|
+
): BuildPathViolation[] {
|
|
149
|
+
const exists = options.exists ?? existsSync;
|
|
150
|
+
const violations: BuildPathViolation[] = [];
|
|
151
|
+
// The simulated working directory of the build. `null` means unknown: the
|
|
152
|
+
// previous cd could not be resolved, so a later relative cd cannot be either.
|
|
153
|
+
let cwd: string | null = options.buildDir;
|
|
154
|
+
for (const rawTarget of findCdTargets(command)) {
|
|
155
|
+
const target = resolveTarget(rawTarget, options);
|
|
156
|
+
if (target === null) {
|
|
157
|
+
cwd = null;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const resolved: string | null = target.startsWith('/')
|
|
161
|
+
? target
|
|
162
|
+
: cwd === null
|
|
163
|
+
? null
|
|
164
|
+
: resolve(cwd, target);
|
|
165
|
+
if (resolved === null) {
|
|
166
|
+
cwd = null;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (!exists(resolved)) {
|
|
170
|
+
violations.push({ rawPath: rawTarget, resolvedPath: resolved });
|
|
171
|
+
}
|
|
172
|
+
cwd = resolved;
|
|
173
|
+
}
|
|
174
|
+
return violations;
|
|
175
|
+
}
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
10
11
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
12
|
import { tmpdir } from 'node:os';
|
|
12
13
|
import { join } from 'node:path';
|
|
13
|
-
import { computeChecksums } from './build';
|
|
14
|
+
import { buildModule, computeChecksums } from './build';
|
|
14
15
|
|
|
15
16
|
describe('computeChecksums — scripts/node_modules bundling (ISS-0046)', () => {
|
|
16
17
|
let dir: string;
|
|
@@ -73,3 +74,72 @@ describe('computeChecksums — scripts/node_modules bundling (ISS-0046)', () =>
|
|
|
73
74
|
expect(paths.some((p) => p.startsWith('e2e/'))).toBe(false);
|
|
74
75
|
});
|
|
75
76
|
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The pack-staging branch must not eat the hook runtime (celilo#1310).
|
|
80
|
+
*
|
|
81
|
+
* `buildModule` stages through `bun pm pack` whenever a module has a root
|
|
82
|
+
* package.json, and npm-pack semantics drop every `node_modules` — a `files`
|
|
83
|
+
* entry naming the path does not override it. That silently contradicted
|
|
84
|
+
* `includeNodeModulesPath`, which exists to ship `scripts/node_modules` in full
|
|
85
|
+
* so hooks resolve their deps on a target with no reachable registry.
|
|
86
|
+
*
|
|
87
|
+
* Nothing in `modules/` can reach this branch: every module there has only
|
|
88
|
+
* `scripts/package.json` and takes the `cpSync` path. So the regression is only
|
|
89
|
+
* visible against a fixture shaped like a module that is ALSO a bun project.
|
|
90
|
+
*/
|
|
91
|
+
describe('buildModule — a root package.json must not strip the hook runtime', () => {
|
|
92
|
+
let source: string;
|
|
93
|
+
let out: string;
|
|
94
|
+
|
|
95
|
+
function write(rel: string, content: string): void {
|
|
96
|
+
const full = join(source, rel);
|
|
97
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
98
|
+
writeFileSync(full, content, 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
source = mkdtempSync(join(tmpdir(), 'celilo-pack-root-pkg-'));
|
|
103
|
+
out = join(mkdtempSync(join(tmpdir(), 'celilo-pack-out-')), 'm.netapp');
|
|
104
|
+
|
|
105
|
+
write('manifest.yml', 'celilo_contract: "1.0"\nid: root-pkg-module\nname: M\nversion: 0.1.0\n');
|
|
106
|
+
// The shape that triggers it: a module that is also a bun project. `files`
|
|
107
|
+
// deliberately names scripts/ — proving the loss is npm-pack semantics and
|
|
108
|
+
// not a missing entry.
|
|
109
|
+
write(
|
|
110
|
+
'package.json',
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
name: 'root-pkg-module',
|
|
113
|
+
version: '0.1.0',
|
|
114
|
+
private: true,
|
|
115
|
+
files: ['manifest.yml', 'scripts/'],
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
write('scripts/on_install.ts', "import '@celilo/capabilities';\n");
|
|
119
|
+
write(
|
|
120
|
+
'scripts/package.json',
|
|
121
|
+
JSON.stringify({ name: 'm-hooks', dependencies: { tldts: '^7.0.0' } }),
|
|
122
|
+
);
|
|
123
|
+
write('scripts/node_modules/tldts/index.js', 'module.exports = {};\n');
|
|
124
|
+
write(
|
|
125
|
+
'scripts/node_modules/tldts/package.json',
|
|
126
|
+
JSON.stringify({ name: 'tldts', version: '7.4.12' }),
|
|
127
|
+
);
|
|
128
|
+
write('scripts/node_modules/.bin/tldts', '#!/bin/sh\n');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
afterEach(() => {
|
|
132
|
+
rmSync(source, { recursive: true, force: true });
|
|
133
|
+
rmSync(join(out, '..'), { recursive: true, force: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('ships scripts/node_modules even though bun pm pack dropped it', async () => {
|
|
137
|
+
const result = await buildModule({ sourceDir: source, outputPath: out });
|
|
138
|
+
expect(result.success).toBe(true);
|
|
139
|
+
|
|
140
|
+
const listed = execFileSync('tar', ['tzf', out], { encoding: 'utf-8' }).split('\n');
|
|
141
|
+
expect(listed).toContain('scripts/node_modules/tldts/index.js');
|
|
142
|
+
// The one thing the runtime closure still excludes.
|
|
143
|
+
expect(listed).not.toContain('scripts/node_modules/.bin/tldts');
|
|
144
|
+
});
|
|
145
|
+
});
|