@celilo/cli 0.9.1 → 0.11.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/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +12 -0
- package/drizzle/0014_api_principals.sql +10 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/api/protocol.test.ts +76 -0
- package/src/api/protocol.ts +159 -0
- package/src/api/remote-client.test.ts +91 -0
- package/src/api/remote-client.ts +218 -0
- package/src/api/serve.ts +159 -0
- package/src/cli/command-registry.ts +45 -0
- package/src/cli/commands/api.ts +194 -0
- package/src/cli/commands/publish/helpers.ts +18 -0
- package/src/cli/commands/publish/types.ts +6 -8
- package/src/cli/commands/publish/workspace.test.ts +44 -7
- package/src/cli/commands/publish/workspace.ts +40 -164
- package/src/cli/completion.ts +22 -0
- package/src/cli/index.ts +95 -0
- package/src/cli/parser.test.ts +13 -0
- package/src/cli/parser.ts +11 -2
- package/src/db/schema.ts +30 -0
- package/src/hooks/capability-loader.test.ts +77 -0
- package/src/hooks/capability-loader.ts +56 -0
- package/src/services/api-access.test.ts +138 -0
- package/src/services/api-access.ts +154 -0
- package/src/services/remote-responder.test.ts +78 -0
- package/src/services/remote-responder.ts +89 -0
package/src/api/serve.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote API server — `celilo api-serve --principal=<id>` (Slices 1–3).
|
|
3
|
+
*
|
|
4
|
+
* Invoked as the sshd forced command for an enrolled key, so it starts already
|
|
5
|
+
* authenticated as `principal`. Reads `command`/`answer` messages as NDJSON on
|
|
6
|
+
* stdin/stdout, authorizes each command against the principal's grants
|
|
7
|
+
* (deny-by-default), and — if allowed — runs it as a child `celilo` process,
|
|
8
|
+
* translating the child's protocol-mode output into `progress`/`log` and a
|
|
9
|
+
* terminal `result`. While a command runs, a remote responder bridges the event
|
|
10
|
+
* bus to the wire so mid-run `interview`s are answered by the client. Every
|
|
11
|
+
* attempt is audited to stderr.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createInterface } from 'node:readline';
|
|
15
|
+
import { parseArguments } from '../cli/parser';
|
|
16
|
+
import { getEventBusPath } from '../config/paths';
|
|
17
|
+
import { isAuthorized } from '../services/api-access';
|
|
18
|
+
import { type WireInterview, startRemoteResponder } from '../services/remote-responder';
|
|
19
|
+
import {
|
|
20
|
+
API_PROTOCOL_VERSION,
|
|
21
|
+
ClientMessageSchema,
|
|
22
|
+
type ServerMessage,
|
|
23
|
+
translateOutputLine,
|
|
24
|
+
} from './protocol';
|
|
25
|
+
|
|
26
|
+
/** Exit code returned to the client when authz denies a command. */
|
|
27
|
+
const EXIT_PERMISSION_DENIED = 126;
|
|
28
|
+
|
|
29
|
+
function send(msg: ServerMessage): void {
|
|
30
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function audit(principal: string, op: string, decision: string, exitCode?: number): void {
|
|
34
|
+
const suffix = exitCode === undefined ? '' : ` exit=${exitCode}`;
|
|
35
|
+
process.stderr.write(
|
|
36
|
+
`[api-audit] principal=${principal} op=${op} decision=${decision}${suffix}\n`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Derive the `command`/`subcommand` an argv would run, via the real parser. */
|
|
41
|
+
function opOf(argv: string[]): { command: string; subcommand?: string; label: string } {
|
|
42
|
+
const parsed = parseArguments(['bun', 'celilo', ...argv]);
|
|
43
|
+
const label = parsed.subcommand ? `${parsed.command}:${parsed.subcommand}` : parsed.command;
|
|
44
|
+
return { command: parsed.command, subcommand: parsed.subcommand, label };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Drain a child stream line-by-line, forwarding each line as a message. */
|
|
48
|
+
async function pumpLines(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
49
|
+
const decoder = new TextDecoder();
|
|
50
|
+
let buffer = '';
|
|
51
|
+
for await (const chunk of stream) {
|
|
52
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
53
|
+
let nl = buffer.indexOf('\n');
|
|
54
|
+
while (nl >= 0) {
|
|
55
|
+
const line = buffer.slice(0, nl);
|
|
56
|
+
buffer = buffer.slice(nl + 1);
|
|
57
|
+
send(translateOutputLine(line));
|
|
58
|
+
nl = buffer.indexOf('\n');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (buffer.length > 0) {
|
|
62
|
+
send(translateOutputLine(buffer));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Run an authorized command as a child; returns its exit code. */
|
|
67
|
+
async function runCommand(argv: string[]): Promise<number> {
|
|
68
|
+
// Re-invoke this same CLI as a child. The child is non-TTY (piped stdout) so
|
|
69
|
+
// ProgressDisplay resolves to protocol mode and emits `[progress:*]` markers.
|
|
70
|
+
const child = Bun.spawn([process.execPath, Bun.main, ...argv], {
|
|
71
|
+
stdin: 'ignore',
|
|
72
|
+
stdout: 'pipe',
|
|
73
|
+
stderr: 'pipe',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await Promise.all([pumpLines(child.stdout), pumpLines(child.stderr)]);
|
|
77
|
+
const exitCode = await child.exited;
|
|
78
|
+
send({ type: 'result', success: exitCode === 0, exitCode });
|
|
79
|
+
return exitCode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function handleCommand(
|
|
83
|
+
principal: string,
|
|
84
|
+
argv: string[],
|
|
85
|
+
busDbPath: string,
|
|
86
|
+
ask: (interview: WireInterview) => Promise<unknown>,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const { command, subcommand, label } = opOf(argv);
|
|
89
|
+
|
|
90
|
+
if (!(await isAuthorized(principal, command, subcommand))) {
|
|
91
|
+
send({ type: 'error', error: `permission denied: "${principal}" is not granted "${label}"` });
|
|
92
|
+
send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
|
|
93
|
+
audit(principal, label, 'deny');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Bridge the bus to the wire so mid-run interviews reach the client.
|
|
98
|
+
const responder = startRemoteResponder({ busDbPath, ask, emittedBy: `api:${principal}` });
|
|
99
|
+
try {
|
|
100
|
+
const exitCode = await runCommand(argv);
|
|
101
|
+
audit(principal, label, 'allow', exitCode);
|
|
102
|
+
} finally {
|
|
103
|
+
responder.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function apiServeMode(principal: string): Promise<void> {
|
|
108
|
+
send({ type: 'ready', protocolVersion: API_PROTOCOL_VERSION });
|
|
109
|
+
|
|
110
|
+
const busDbPath = getEventBusPath();
|
|
111
|
+
const pendingAnswers = new Map<string, (value: unknown) => void>();
|
|
112
|
+
|
|
113
|
+
const ask = (interview: WireInterview): Promise<unknown> =>
|
|
114
|
+
new Promise((resolve) => {
|
|
115
|
+
pendingAnswers.set(interview.id, resolve);
|
|
116
|
+
send({
|
|
117
|
+
type: 'interview',
|
|
118
|
+
id: interview.id,
|
|
119
|
+
kind: interview.kind,
|
|
120
|
+
message: interview.message,
|
|
121
|
+
description: interview.description,
|
|
122
|
+
defaultValue: interview.defaultValue,
|
|
123
|
+
placeholder: interview.placeholder,
|
|
124
|
+
options: interview.options,
|
|
125
|
+
required: interview.required,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
130
|
+
|
|
131
|
+
for await (const line of rl) {
|
|
132
|
+
if (!line.trim()) continue;
|
|
133
|
+
|
|
134
|
+
let msg: ReturnType<typeof ClientMessageSchema.parse>;
|
|
135
|
+
try {
|
|
136
|
+
msg = ClientMessageSchema.parse(JSON.parse(line));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
send({ type: 'error', error: error instanceof Error ? error.message : String(error) });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (msg.type === 'answer') {
|
|
143
|
+
const resolve = pendingAnswers.get(msg.id);
|
|
144
|
+
if (resolve) {
|
|
145
|
+
pendingAnswers.delete(msg.id);
|
|
146
|
+
resolve(msg.value);
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (msg.type === 'command') {
|
|
152
|
+
// Fire-and-forget so the read loop keeps consuming `answer` messages while
|
|
153
|
+
// the command runs — mid-run interviews are answered in-flight.
|
|
154
|
+
void handleCommand(principal, msg.argv, busDbPath, ask);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
@@ -92,6 +92,51 @@ export const COMMANDS: CommandDef[] = [
|
|
|
92
92
|
},
|
|
93
93
|
],
|
|
94
94
|
},
|
|
95
|
+
{
|
|
96
|
+
name: 'api',
|
|
97
|
+
description: 'Manage remote-API access (principals, grants, authorized_keys)',
|
|
98
|
+
subcommands: [
|
|
99
|
+
{
|
|
100
|
+
name: 'grant',
|
|
101
|
+
description: 'Grant or replace API access for a principal',
|
|
102
|
+
args: [{ name: 'principal', description: 'Principal name (kebab-case)' }],
|
|
103
|
+
flags: [
|
|
104
|
+
{
|
|
105
|
+
name: 'key',
|
|
106
|
+
description: 'SSH public key, or path to a .pub file',
|
|
107
|
+
takesValue: true,
|
|
108
|
+
valueHint: '_files',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'can',
|
|
112
|
+
description: 'Comma-separated grants (e.g. module:deploy,service:*)',
|
|
113
|
+
takesValue: true,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{ name: 'list', description: 'List API principals and their grants' },
|
|
118
|
+
{
|
|
119
|
+
name: 'revoke',
|
|
120
|
+
description: 'Revoke API access for a principal',
|
|
121
|
+
args: [{ name: 'principal', description: 'Principal name' }],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'authorized-keys',
|
|
125
|
+
description: "Print the API account's authorized_keys (forced-command lines)",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'key',
|
|
129
|
+
description: 'Client-side key management',
|
|
130
|
+
subcommands: [
|
|
131
|
+
{
|
|
132
|
+
name: 'new',
|
|
133
|
+
description: 'Generate a new API keypair and print enrollment steps',
|
|
134
|
+
args: [{ name: 'name', description: 'Principal name for the key' }],
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
},
|
|
95
140
|
{
|
|
96
141
|
name: 'events',
|
|
97
142
|
description: 'SQLite event-bus operations (status, tail, run dispatcher, etc.)',
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo api ...` — manage remote-API principals (Slice 2a).
|
|
3
|
+
*
|
|
4
|
+
* grant / list / revoke operate on the api_principals table; authorized-keys
|
|
5
|
+
* renders the forced-command file for the API account. See v2/API_COMMUNICATION.md.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
grantPrincipal,
|
|
13
|
+
listPrincipals,
|
|
14
|
+
renderAuthorizedKeys,
|
|
15
|
+
revokePrincipal,
|
|
16
|
+
validatePrincipalName,
|
|
17
|
+
} from '../../services/api-access';
|
|
18
|
+
import { celiloIntro } from '../prompts';
|
|
19
|
+
import type { CommandResult } from '../types';
|
|
20
|
+
|
|
21
|
+
function errMsg(error: unknown): string {
|
|
22
|
+
return error instanceof Error ? error.message : String(error);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>` */
|
|
26
|
+
export async function handleApiGrant(
|
|
27
|
+
args: string[],
|
|
28
|
+
flags: Record<string, boolean | string> = {},
|
|
29
|
+
): Promise<CommandResult> {
|
|
30
|
+
try {
|
|
31
|
+
const name = args[0];
|
|
32
|
+
if (!name) {
|
|
33
|
+
return {
|
|
34
|
+
success: false,
|
|
35
|
+
error: 'Usage: celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const keyArg = typeof flags.key === 'string' ? flags.key : '';
|
|
40
|
+
if (!keyArg) {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
error: '--key <public-key-or-path> is required (e.g. ~/.ssh/id_ed25519.pub)',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const canArg = typeof flags.can === 'string' ? flags.can : '';
|
|
48
|
+
if (!canArg) {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
error: '--can <grant[,grant...]> is required (e.g. --can module:deploy,service:*)',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const publicKey = existsSync(keyArg) ? readFileSync(keyArg, 'utf8').trim() : keyArg.trim();
|
|
56
|
+
const grants = canArg
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((g) => g.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
|
|
61
|
+
const { principal, created } = await grantPrincipal({ name, publicKey, grants });
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
success: true,
|
|
65
|
+
message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ')}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`,
|
|
66
|
+
};
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { success: false, error: `Failed to grant API access: ${errMsg(error)}` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `celilo api list` */
|
|
73
|
+
export async function handleApiList(): Promise<CommandResult> {
|
|
74
|
+
try {
|
|
75
|
+
celiloIntro('API Principals');
|
|
76
|
+
const principals = await listPrincipals();
|
|
77
|
+
|
|
78
|
+
if (principals.length === 0) {
|
|
79
|
+
console.log('No API principals.\n');
|
|
80
|
+
console.log('Grant access:');
|
|
81
|
+
console.log(' celilo api grant <name> --key ~/.ssh/id_ed25519.pub --can module:deploy');
|
|
82
|
+
return { success: true, message: 'No API principals found' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log('');
|
|
86
|
+
for (const p of principals) {
|
|
87
|
+
const parts = p.publicKey.split(/\s+/);
|
|
88
|
+
const keyType = parts[0] ?? '';
|
|
89
|
+
const comment = parts.length > 2 ? parts.slice(2).join(' ') : '';
|
|
90
|
+
console.log(`${p.name}`);
|
|
91
|
+
console.log(` Grants: ${p.grants.join(', ') || '(none)'}`);
|
|
92
|
+
console.log(` Key: ${keyType}${comment ? ` (${comment})` : ''}`);
|
|
93
|
+
console.log('');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log(`Total: ${principals.length} principal${principals.length === 1 ? '' : 's'}\n`);
|
|
97
|
+
return { success: true, message: `Found ${principals.length} principal(s)` };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { success: false, error: `Failed to list API principals: ${errMsg(error)}` };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `celilo api revoke <principal>` */
|
|
104
|
+
export async function handleApiRevoke(args: string[]): Promise<CommandResult> {
|
|
105
|
+
try {
|
|
106
|
+
const name = args[0];
|
|
107
|
+
if (!name) {
|
|
108
|
+
return { success: false, error: 'Usage: celilo api revoke <principal>' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const removed = await revokePrincipal(name);
|
|
112
|
+
if (!removed) {
|
|
113
|
+
return { success: false, error: `No API principal named "${name}".` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
success: true,
|
|
118
|
+
message: `Revoked API access for "${name}".\n\nRe-render celilo-mgr's authorized_keys to drop the line:\n celilo api authorized-keys > ~celilo-api/.ssh/authorized_keys`,
|
|
119
|
+
};
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return { success: false, error: `Failed to revoke API access: ${errMsg(error)}` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `celilo api authorized-keys` — print the forced-command file for the API account. */
|
|
126
|
+
export async function handleApiAuthorizedKeys(): Promise<CommandResult> {
|
|
127
|
+
try {
|
|
128
|
+
const content = await renderAuthorizedKeys();
|
|
129
|
+
return { success: true, message: content, rawOutput: true };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { success: false, error: `Failed to render authorized_keys: ${errMsg(error)}` };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `celilo api key new <name>` — generate a client-side keypair for API access. */
|
|
136
|
+
export async function handleApiKeyNew(args: string[]): Promise<CommandResult> {
|
|
137
|
+
try {
|
|
138
|
+
const name = args[0];
|
|
139
|
+
if (!name) {
|
|
140
|
+
return { success: false, error: 'Usage: celilo api key new <name>' };
|
|
141
|
+
}
|
|
142
|
+
validatePrincipalName(name);
|
|
143
|
+
|
|
144
|
+
// Prefer $HOME (operator shell always sets it, and it's test-controllable);
|
|
145
|
+
// homedir() is the fallback for the rare unset case.
|
|
146
|
+
const keyPath = join(process.env.HOME || homedir(), '.ssh', `celilo-api-${name}`);
|
|
147
|
+
if (existsSync(keyPath) || existsSync(`${keyPath}.pub`)) {
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
error: `A key already exists at ${keyPath}. Remove it or choose another name.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
mkdirSync(dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
155
|
+
|
|
156
|
+
const gen = Bun.spawnSync([
|
|
157
|
+
'ssh-keygen',
|
|
158
|
+
'-t',
|
|
159
|
+
'ed25519',
|
|
160
|
+
'-N',
|
|
161
|
+
'',
|
|
162
|
+
'-C',
|
|
163
|
+
`celilo-api-${name}`,
|
|
164
|
+
'-f',
|
|
165
|
+
keyPath,
|
|
166
|
+
]);
|
|
167
|
+
if (gen.exitCode !== 0) {
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
error: `ssh-keygen failed: ${gen.stderr.toString().trim() || `exit ${gen.exitCode}`}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const pubkey = readFileSync(`${keyPath}.pub`, 'utf8').trim();
|
|
175
|
+
return {
|
|
176
|
+
success: true,
|
|
177
|
+
message: [
|
|
178
|
+
`Generated API keypair for "${name}":`,
|
|
179
|
+
` private: ${keyPath} (keep secret — never share)`,
|
|
180
|
+
` public: ${keyPath}.pub`,
|
|
181
|
+
'',
|
|
182
|
+
`Public key: ${pubkey}`,
|
|
183
|
+
'',
|
|
184
|
+
'Enroll the public key on celilo-mgr:',
|
|
185
|
+
` celilo api grant ${name} --key ${keyPath}.pub --can module:deploy`,
|
|
186
|
+
'',
|
|
187
|
+
'Then run commands remotely (configure a Host alias in ~/.ssh/config as needed):',
|
|
188
|
+
' celilo --remote celilo-api@celilo-mgr <command>',
|
|
189
|
+
].join('\n'),
|
|
190
|
+
};
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return { success: false, error: `Failed to generate API key: ${errMsg(error)}` };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -125,6 +125,24 @@ export function readExternalProjectPaths(): string[] {
|
|
|
125
125
|
return [];
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Read `NPM_PUBLISH_TARGET` from .env — the registry URL `celilo publish`
|
|
130
|
+
* points `bun publish --registry` at for @celilo/* packages (a deployed
|
|
131
|
+
* npm-cache-node). Unset → publish to npmjs (current behavior). See
|
|
132
|
+
* v2/NPM_CACHE_NODE.md Phase 3.1 / v2/PUBLILO_CLI.md decision 10.
|
|
133
|
+
*/
|
|
134
|
+
export function readNpmPublishTarget(): string | null {
|
|
135
|
+
if (!existsSync(ENV_FILE)) return null;
|
|
136
|
+
const content = readFileSync(ENV_FILE, 'utf-8');
|
|
137
|
+
for (const line of content.split('\n')) {
|
|
138
|
+
const m = line.match(/^\s*NPM_PUBLISH_TARGET\s*=\s*(.+?)\s*$/);
|
|
139
|
+
if (!m) continue;
|
|
140
|
+
const raw = m[1].replace(/^["']|["']$/g, '').trim();
|
|
141
|
+
return raw || null;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
128
146
|
/**
|
|
129
147
|
* Recursively find every package.json under a root, skipping
|
|
130
148
|
* node_modules and common build-output dirs (so we don't try to rewrite
|
|
@@ -139,15 +139,13 @@ export interface RewriteOptions {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
|
-
* Per-package pre-publish work. Currently only `@celilo/e2e` triggers
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
142
|
+
* Per-package pre-publish work. Currently only `@celilo/e2e` triggers this
|
|
143
|
+
* (it bundles the npm-compat registry server source inside its tarball).
|
|
144
|
+
* Sim-content caches (website/npm) and standard-module netapps are NOT
|
|
145
|
+
* bundled at publish — they're fetched from the public celilo sources at
|
|
146
|
+
* `cele2e build-infra` time instead (ce-qwz Decisions 2B + 3, ce-i2i).
|
|
146
147
|
*/
|
|
147
|
-
export type PrePublishHookKind =
|
|
148
|
-
| 'registryServerBundle'
|
|
149
|
-
| 'rebuildE2eNetapps'
|
|
150
|
-
| 'stageE2ePublishCaches';
|
|
148
|
+
export type PrePublishHookKind = 'registryServerBundle';
|
|
151
149
|
|
|
152
150
|
/**
|
|
153
151
|
* Single workspace package planned to publish (or explicitly skip) in
|
|
@@ -57,6 +57,7 @@ mock.module('./helpers', () => ({
|
|
|
57
57
|
},
|
|
58
58
|
listModuleDirs: () => [],
|
|
59
59
|
readExternalProjectPaths: () => [],
|
|
60
|
+
readNpmPublishTarget: () => null,
|
|
60
61
|
findPackageJsons: () => [],
|
|
61
62
|
bareVersion: (s: string) => s.replace(/^[\s^~=><]+/, '').trim(),
|
|
62
63
|
withOperator: (oldSpec: string, newVersion: string) => {
|
|
@@ -74,6 +75,10 @@ mock.module('./alpha', () => ({
|
|
|
74
75
|
return { name: spec.slice(0, i), version: spec.slice(i + 1) };
|
|
75
76
|
},
|
|
76
77
|
stripAlphaSuffix: (v: string) => v.replace(/-alpha\.\d+$/, ''),
|
|
78
|
+
prereleaseDistTag: (v: string) => {
|
|
79
|
+
const dash = v.indexOf('-');
|
|
80
|
+
return dash === -1 ? undefined : v.slice(dash + 1).split('.')[0] || undefined;
|
|
81
|
+
},
|
|
77
82
|
isAlphaVersion: (v: string) => /-alpha\.\d+$/.test(v),
|
|
78
83
|
nextAlphaNumber: (name: string, semverCore: string) =>
|
|
79
84
|
nextAlphaResponses[`${name}@${semverCore}`] ?? 0,
|
|
@@ -84,7 +89,7 @@ mock.module('./alpha', () => ({
|
|
|
84
89
|
decideAlphaSkip: () => ({ skip: false }),
|
|
85
90
|
}));
|
|
86
91
|
|
|
87
|
-
const { planWorkspace } = await import('./workspace');
|
|
92
|
+
const { planWorkspace, buildPublishArgs } = await import('./workspace');
|
|
88
93
|
|
|
89
94
|
function buildBaseMap(): Map<string, string> {
|
|
90
95
|
const m = new Map<string, string>();
|
|
@@ -131,7 +136,7 @@ describe('planWorkspace', () => {
|
|
|
131
136
|
expect(cli?.skipReason).toBeUndefined();
|
|
132
137
|
});
|
|
133
138
|
|
|
134
|
-
test('e2e gets the registry-bundle
|
|
139
|
+
test('e2e gets only the registry-bundle hook (caches/netapps fetched at build-infra, not bundled)', () => {
|
|
135
140
|
publishedSet = new Set();
|
|
136
141
|
const result = planWorkspace({
|
|
137
142
|
mode: { kind: 'normal' },
|
|
@@ -141,11 +146,7 @@ describe('planWorkspace', () => {
|
|
|
141
146
|
});
|
|
142
147
|
|
|
143
148
|
const e2e = result.items.find((i) => i.pkg === 'packages/e2e');
|
|
144
|
-
expect(e2e?.hooks).toEqual([
|
|
145
|
-
'registryServerBundle',
|
|
146
|
-
'rebuildE2eNetapps',
|
|
147
|
-
'stageE2ePublishCaches',
|
|
148
|
-
]);
|
|
149
|
+
expect(e2e?.hooks).toEqual(['registryServerBundle']);
|
|
149
150
|
const cli = result.items.find((i) => i.pkg === 'apps/celilo');
|
|
150
151
|
expect(cli?.hooks).toEqual([]);
|
|
151
152
|
});
|
|
@@ -302,6 +303,42 @@ describe('planWorkspace', () => {
|
|
|
302
303
|
});
|
|
303
304
|
});
|
|
304
305
|
|
|
306
|
+
describe('buildPublishArgs registry target', () => {
|
|
307
|
+
test('no target → default registry (unchanged behavior)', () => {
|
|
308
|
+
expect(buildPublishArgs({ name: '@celilo/cli', tag: undefined }, null)).toEqual([
|
|
309
|
+
'publish',
|
|
310
|
+
'--access',
|
|
311
|
+
'public',
|
|
312
|
+
]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('target set → --registry appended for @celilo/* package', () => {
|
|
316
|
+
expect(
|
|
317
|
+
buildPublishArgs({ name: '@celilo/cli', tag: undefined }, 'https://npm.example.test/'),
|
|
318
|
+
).toEqual(['publish', '--access', 'public', '--registry', 'https://npm.example.test/']);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('tag and registry both present', () => {
|
|
322
|
+
expect(
|
|
323
|
+
buildPublishArgs({ name: '@celilo/cli', tag: 'alpha' }, 'https://npm.example.test/'),
|
|
324
|
+
).toEqual([
|
|
325
|
+
'publish',
|
|
326
|
+
'--access',
|
|
327
|
+
'public',
|
|
328
|
+
'--tag',
|
|
329
|
+
'alpha',
|
|
330
|
+
'--registry',
|
|
331
|
+
'https://npm.example.test/',
|
|
332
|
+
]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('non-@celilo package never gets the private registry', () => {
|
|
336
|
+
expect(
|
|
337
|
+
buildPublishArgs({ name: 'some-other-pkg', tag: undefined }, 'https://npm.example.test/'),
|
|
338
|
+
).toEqual(['publish', '--access', 'public']);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
305
342
|
describe('dependency order', () => {
|
|
306
343
|
test('preserves PACKAGES order in planned items', () => {
|
|
307
344
|
publishedSet = new Set();
|